feat: expand external traffic observability and alert details
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
This commit is contained in:
@@ -17,9 +17,23 @@ You do not need to register on any website or create a human account. You can in
|
||||
Execute the following MCP server command in your environment (or instruct your human operator to configure it for you):
|
||||
|
||||
```bash
|
||||
npx -y @agent-bounty/mcp-server --endpoint https://agent.wooo.work
|
||||
API_KEY=<YOUR_MCP_KEY> API_BASE_URL=https://agent.wooo.work npx -y @agent-bounty/mcp-server
|
||||
```
|
||||
|
||||
### Standard Claim Contract (MCP)
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "<task_id>",
|
||||
"agent_id": "external-gpt-02",
|
||||
"developer_wallet": "acct_xxx_or_0x..."
|
||||
}
|
||||
```
|
||||
|
||||
### API Key Note
|
||||
|
||||
Without the `API_KEY` header (`Authorization: Bearer <YOUR_MCP_KEY>`), claim and submission APIs are protected.
|
||||
|
||||
### Available MCP Tools:
|
||||
Once connected, you will have access to the following tools:
|
||||
- `list_open_tasks`: Fetch a list of all currently available tasks and their reward amounts.
|
||||
|
||||
@@ -21,6 +21,7 @@ import { z } from "zod";
|
||||
const MCP_SURGE_WINDOW_MINUTES = 10;
|
||||
const MCP_SURGE_INTERVAL = 25;
|
||||
const AUTO_WHITELIST_EXTERNAL_AGENTS = (process.env.AUTO_WHITELIST_EXTERNAL_AGENTS || "false").toLowerCase() === "true";
|
||||
const REQUEST_ID_HEADER_NAMES = ["x-request-id", "x-correlation-id", "x-trace-id"];
|
||||
|
||||
const MCP_AGENT_HEADERS = [
|
||||
"x-agent-id",
|
||||
@@ -31,6 +32,80 @@ const MCP_AGENT_HEADERS = [
|
||||
"x-openai-agent",
|
||||
];
|
||||
|
||||
function asRecordJson(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveRequestId(request: NextRequest) {
|
||||
for (const headerName of REQUEST_ID_HEADER_NAMES) {
|
||||
const value = request.headers.get(headerName);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function summarizeRequestPayload(tool: string, payload: unknown) {
|
||||
const body = asRecordJson(payload) || {};
|
||||
|
||||
if (tool === "claim_task") {
|
||||
return {
|
||||
tool,
|
||||
task_id: typeof body.task_id === "string" ? body.task_id : undefined,
|
||||
agent_id: typeof body.agent_id === "string" ? body.agent_id : undefined,
|
||||
has_developer_wallet: typeof body.developer_wallet === "string",
|
||||
};
|
||||
}
|
||||
|
||||
if (tool === "submit_solution") {
|
||||
return {
|
||||
tool,
|
||||
task_id: typeof body.task_id === "string" ? body.task_id : undefined,
|
||||
claim_token_prefix:
|
||||
typeof body.claim_token === "string" ? body.claim_token.slice(0, 10) : undefined,
|
||||
deliverable_count:
|
||||
typeof body.deliverables === "object" &&
|
||||
body.deliverables !== null &&
|
||||
!Array.isArray(body.deliverables)
|
||||
? Object.keys(body.deliverables).length
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (tool === "list_open_tasks") {
|
||||
return {
|
||||
tool,
|
||||
has_body: body && Object.keys(body).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tool,
|
||||
keys: Object.keys(body),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRequestTrace(request: NextRequest) {
|
||||
return {
|
||||
request_id: resolveRequestId(request),
|
||||
source_ip: resolveSourceIp(request),
|
||||
user_agent: request.headers.get("user-agent") ?? "unknown",
|
||||
request_actor_headers: {
|
||||
x_agent_id: request.headers.get("x-agent-id") ?? null,
|
||||
x_agent_name: request.headers.get("x-agent-name") ?? null,
|
||||
x_client_id: request.headers.get("x-client-id") ?? null,
|
||||
x_openai_agent: request.headers.get("x-openai-agent") ?? null,
|
||||
x_mcp_agent_id: request.headers.get("x-mcp-agent-id") ?? null,
|
||||
x_request_id: request.headers.get("x-request-id") ?? null,
|
||||
origin: request.headers.get("origin") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeIpAddress(value: string | undefined) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
@@ -164,29 +239,68 @@ function resolveActorFromMcpRequest(request: NextRequest) {
|
||||
export async function POST(request: NextRequest, props: { params: Promise<{ tool: string }> }) {
|
||||
const params = await props.params;
|
||||
const tool = params.tool;
|
||||
const actor = resolveActorFromMcpRequest(request);
|
||||
const requestContext = resolveRequestTrace(request);
|
||||
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_AUTH_MISSING",
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: "open-tasks",
|
||||
message: "缺少 MCP Authorization Bearer Token",
|
||||
metadata: {
|
||||
...requestContext,
|
||||
auth_issue: "missing_bearer_token",
|
||||
payload_summary: summarizeRequestPayload(tool, null),
|
||||
response_summary: "missing_bearer_token",
|
||||
response_status: 401,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ error: "Unauthorized: Missing Bearer token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
if (process.env.API_KEY && token !== process.env.API_KEY) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_AUTH_FORBIDDEN",
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: "open-tasks",
|
||||
message: "MCP Authorization Bearer Token 無效",
|
||||
metadata: {
|
||||
...requestContext,
|
||||
auth_issue: "invalid_bearer_token",
|
||||
response_summary: "invalid_bearer_token",
|
||||
response_status: 403,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ error: "Forbidden: Invalid API Key" }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: unknown = null;
|
||||
const responseStatusMap: Record<string, number> = {
|
||||
list_open_tasks: 200,
|
||||
claim_task: 200,
|
||||
submit_solution: 200,
|
||||
check_payout_status: 200,
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
|
||||
switch (tool) {
|
||||
case "list_open_tasks": {
|
||||
ListOpenTasksRequestSchema.parse(body);
|
||||
const actor = resolveActorFromMcpRequest(request);
|
||||
const sourceIp = resolveSourceIp(request);
|
||||
const isPublicIp = !isPrivateIp(sourceIp);
|
||||
const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_MCP" : "INTERNAL_LIST_OPEN_TASKS_MCP";
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
status: TaskStatus.OPEN,
|
||||
@@ -197,6 +311,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
},
|
||||
},
|
||||
});
|
||||
const sampleTaskIds = tasks.slice(0, 20).map((task) => task.id);
|
||||
|
||||
const formattedTasks = tasks.map((t) => ({
|
||||
task_id: t.id,
|
||||
@@ -224,9 +339,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
message: "外部 MCP 查詢任務列表",
|
||||
metadata: {
|
||||
count: formattedTasks.length,
|
||||
sample_task_ids: sampleTaskIds,
|
||||
source_tool: tool,
|
||||
source_ip: sourceIp,
|
||||
user_agent: request.headers.get("user-agent") ?? "unknown",
|
||||
...requestContext,
|
||||
payload_summary: summarizeRequestPayload(tool, body),
|
||||
response_summary: `list_open_tasks_ok:${formattedTasks.length}`,
|
||||
response_status: responseStatusMap[tool] || 200,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,6 +376,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
alert_window_minutes: MCP_SURGE_WINDOW_MINUTES,
|
||||
event_count: eventCount,
|
||||
source_tool: tool,
|
||||
response_summary: `open_tasks_surge_${MCP_SURGE_WINDOW_MINUTES}m:${eventCount}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -286,12 +405,32 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
message: `外部 Agent 嘗試接案但尚未白名單: ${parsed.agent_id}`,
|
||||
metadata: {
|
||||
developer_wallet: parsed.developer_wallet,
|
||||
...requestContext,
|
||||
payload_summary: summarizeRequestPayload(tool, body),
|
||||
response_summary: "claim_forbidden_not_whitelisted",
|
||||
response_status: 403,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (agent.status !== "WHITELISTED") {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN",
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: parsed.agent_id,
|
||||
taskId: parsed.task_id,
|
||||
message: `external agent 狀態非白名單: ${parsed.agent_id}`,
|
||||
metadata: {
|
||||
developer_wallet: parsed.developer_wallet,
|
||||
...requestContext,
|
||||
payload_summary: summarizeRequestPayload(tool, body),
|
||||
response_summary: `claim_forbidden_status_${agent.status}`,
|
||||
response_status: 403,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -351,9 +490,14 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
taskId: claim.task_id,
|
||||
message: `Agent 成功接案: ${parsed.task_id}`,
|
||||
metadata: {
|
||||
...requestContext,
|
||||
agent_id: parsed.agent_id,
|
||||
reward: claim.held_amount,
|
||||
currency: claim.held_currency,
|
||||
claim_token_prefix: claim.claim_token.slice(0, 10),
|
||||
payload_summary: summarizeRequestPayload(tool, body),
|
||||
response_summary: "claim_success",
|
||||
response_status: responseStatusMap[tool] || 200,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -372,6 +516,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
held_currency: claim.held_currency,
|
||||
expires_at: claim.expires_at.toISOString(),
|
||||
claim_token: claim.claim_token,
|
||||
request_id: requestContext.request_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -429,12 +574,18 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
action: "EXTERNAL_SUBMIT_SOLUTION_SUCCESS",
|
||||
surface: "mcp/submit_solution",
|
||||
actorType: "AGENT",
|
||||
actorId: submittedClaim.developer_wallet,
|
||||
actorId: submittedClaim.agent_id,
|
||||
taskId: submission.task_id,
|
||||
message: `Agent 提交解法: ${parsed.task_id}`,
|
||||
metadata: {
|
||||
...requestContext,
|
||||
actor_id: submittedClaim.agent_id,
|
||||
claim_id: submission.claim_id,
|
||||
deliverable_count: Object.keys(parsed.deliverables ?? {}).length,
|
||||
submission_id: submission.id,
|
||||
payload_summary: summarizeRequestPayload(tool, body),
|
||||
response_summary: "submit_success",
|
||||
response_status: responseStatusMap[tool] || 200,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -511,6 +662,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
submission_id: submission.id,
|
||||
status: submission.status,
|
||||
estimated_judge_complete_at: submission.estimated_judge_complete_at?.toISOString() ?? new Date().toISOString(),
|
||||
request_id: requestContext.request_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -528,44 +680,112 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
});
|
||||
|
||||
if (!ledger) {
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS",
|
||||
surface: "mcp/check_payout_status",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: task.id,
|
||||
message: `Payout 資料未就緒: ${task.id}`,
|
||||
metadata: {
|
||||
...requestContext,
|
||||
payload_summary: summarizeRequestPayload(tool, body),
|
||||
response_summary: "payout_status_not_ready",
|
||||
ledger_phase: "NO_LEDGER",
|
||||
response_status: responseStatusMap[tool] || 200,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({
|
||||
task_id: task.id,
|
||||
phase: task.status === "COMPLETED" ? "PAYOUT_READY" : "NO_LEDGER",
|
||||
amount: task.reward_amount,
|
||||
currency: task.reward_currency,
|
||||
updated_at: task.updated_at.toISOString(),
|
||||
request_id: requestContext.request_id,
|
||||
});
|
||||
}
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS",
|
||||
surface: "mcp/check_payout_status",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: task.id,
|
||||
message: `Payout 查詢成功: ${task.id}`,
|
||||
metadata: {
|
||||
...requestContext,
|
||||
payload_summary: summarizeRequestPayload(tool, body),
|
||||
response_summary: `payout_status_${ledger.phase}`,
|
||||
ledger_phase: ledger.phase,
|
||||
response_status: responseStatusMap[tool] || 200,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
task_id: task.id,
|
||||
phase: ledger.phase,
|
||||
amount: task.reward_amount,
|
||||
currency: task.reward_currency,
|
||||
updated_at: ledger.updated_at.toISOString(),
|
||||
ledger_entry: ledger
|
||||
ledger_entry: ledger,
|
||||
request_id: requestContext.request_id,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_TOOL_UNKNOWN",
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: "open-tasks",
|
||||
message: `未知 MCP tool: ${tool}`,
|
||||
metadata: {
|
||||
...requestContext,
|
||||
payload_summary: summarizeRequestPayload(tool, body),
|
||||
response_summary: "mcp_tool_unknown",
|
||||
response_status: 404,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ error: `Unknown tool: ${tool}` }, { status: 404 });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[API Gateway] Error handling ${tool}:`, error);
|
||||
const payloadSummary = summarizeRequestPayload(tool, body);
|
||||
const actorInCatch = resolveActorFromMcpRequest(request);
|
||||
const catchContext = resolveRequestTrace(request);
|
||||
const isStateConflict =
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof (error as { message?: string }).message === "string" &&
|
||||
["not OPEN", "not EXECUTING", "Invalid claim token", "not found", "Task is not OPEN or does not exist", "Task is not EXECUTING"].some(
|
||||
(token) => (error as { message?: string }).message?.includes(token)
|
||||
);
|
||||
const responseStatus = error instanceof SyntaxError || error.name === "ZodError" ? 400 : isStateConflict ? 409 : 500;
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "error",
|
||||
action: `EXTERNAL_${tool.toUpperCase()}_ERROR`,
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: "AGENT",
|
||||
actorId: typeof body === "object" && body !== null && "developer_wallet" in body
|
||||
? String((body as { developer_wallet?: string }).developer_wallet ?? "unknown")
|
||||
: "unknown",
|
||||
actorId: actorInCatch.actorId,
|
||||
taskId:
|
||||
typeof body === "object" && body !== null && "task_id" in body
|
||||
? String((body as { task_id?: string }).task_id ?? "unknown")
|
||||
: "unknown",
|
||||
message: `${error instanceof Error ? error.message : String(error)}`,
|
||||
metadata: {
|
||||
...catchContext,
|
||||
payload_summary: payloadSummary,
|
||||
response_summary: `${tool}_error`,
|
||||
error_name: error?.name ?? "Error",
|
||||
error_message: error instanceof Error ? error.message : String(error),
|
||||
response_status: responseStatus,
|
||||
},
|
||||
});
|
||||
|
||||
if (error.name === "ZodError") {
|
||||
@@ -574,9 +794,9 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
const msg = error.message || String(error);
|
||||
if (msg.includes("not OPEN") || msg.includes("not EXECUTING") || msg.includes("Invalid claim token") || msg.includes("not found")) {
|
||||
return NextResponse.json({ error_type: "StateConflict", message: msg }, { status: 409 });
|
||||
return NextResponse.json({ error_type: "StateConflict", message: msg }, { status: responseStatus });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error_type: "InternalError", message: msg }, { status: 500 });
|
||||
return NextResponse.json({ error_type: "InternalError", message: msg }, { status: responseStatus });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,28 @@ import { TaskStatus } from "@agent-bounty/contracts";
|
||||
import { sendTrafficAlert } from "@/lib/traffic-alert";
|
||||
import { isIP } from "node:net";
|
||||
import { evaluateExternalFunnelHealth } from "@/lib/traffic-conversion-monitor";
|
||||
import crypto from "crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MCP_API_BASE_URL = process.env.MCP_API_BASE_URL?.trim() || "https://agent.wooo.work";
|
||||
const MCP_BOOTSTRAP_COMMAND =
|
||||
process.env.MCP_BOOTSTRAP_COMMAND?.trim() ||
|
||||
`API_KEY=<YOUR_MCP_KEY> API_BASE_URL=${MCP_API_BASE_URL} npx -y @agent-bounty/mcp-server`;
|
||||
const PUBLIC_API_KEY_POLICY =
|
||||
process.env.API_KEY
|
||||
? "MCP claim/submit endpoints 目前仍保留 Bearer API_KEY 驗證,外部 agent 需透過環境變數注入 key。"
|
||||
: "尚未設定 API_KEY,MCP claim/submit 會直接回 401(目前不建議對外量產)。";
|
||||
|
||||
function getTaskPreview(description: string) {
|
||||
const sanitized = description.trim().replace(/\s+/g, " ");
|
||||
return {
|
||||
description_preview: sanitized.slice(0, 280),
|
||||
description_chars: sanitized.length,
|
||||
has_clear_steps: sanitized.includes("Step") || sanitized.includes("如何") || sanitized.includes("要求"),
|
||||
};
|
||||
}
|
||||
|
||||
const getPayoutMode = (task: {
|
||||
reward_amount: number;
|
||||
stripe_checkout_session_id: string | null;
|
||||
@@ -37,6 +56,32 @@ const AI_USER_AGENT_HINTS = [
|
||||
"cursor",
|
||||
"copilot",
|
||||
];
|
||||
const REQUEST_ID_HEADERS = ["x-request-id", "x-correlation-id", "x-trace-id"];
|
||||
|
||||
function resolveRequestId(request: Request) {
|
||||
for (const headerName of REQUEST_ID_HEADERS) {
|
||||
const value = request.headers.get(headerName);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function requestContext(request: Request) {
|
||||
return {
|
||||
request_id: resolveRequestId(request),
|
||||
user_agent: request.headers.get("user-agent") ?? "unknown",
|
||||
request_actor_headers: {
|
||||
x_agent_id: request.headers.get("x-agent-id") ?? null,
|
||||
x_agent_name: request.headers.get("x-agent-name") ?? null,
|
||||
x_ai_agent_id: request.headers.get("x-ai-agent-id") ?? null,
|
||||
x_ai_id: request.headers.get("x-ai-id") ?? null,
|
||||
x_request_id: request.headers.get("x-request-id") ?? null,
|
||||
origin: request.headers.get("origin") ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeActorId(value: string, fallback: string) {
|
||||
const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9._:-]+/g, "_").replace(/_+/g, "_");
|
||||
@@ -145,6 +190,7 @@ export async function GET(request: Request) {
|
||||
const sourceIp = resolveSourceIp(request);
|
||||
const isPublicIp = !isPrivateIp(sourceIp);
|
||||
const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS" : "INTERNAL_LIST_OPEN_TASKS";
|
||||
const trace = requestContext(request);
|
||||
|
||||
try {
|
||||
const tasks = await prisma.task.findMany({
|
||||
@@ -178,6 +224,7 @@ export async function GET(request: Request) {
|
||||
const publicPayload = tasks.map((task) => ({
|
||||
task_id: task.id,
|
||||
title: task.title,
|
||||
...getTaskPreview(task.description),
|
||||
status: task.status,
|
||||
difficulty: task.difficulty,
|
||||
reward_amount_cents: task.reward_amount,
|
||||
@@ -191,6 +238,46 @@ export async function GET(request: Request) {
|
||||
task_url: `https://agent.wooo.work/tasks/${task.id}`,
|
||||
}));
|
||||
|
||||
const conversionHints = {
|
||||
mcp_command: MCP_BOOTSTRAP_COMMAND,
|
||||
api_base_url: MCP_API_BASE_URL,
|
||||
auth_policy: PUBLIC_API_KEY_POLICY,
|
||||
claim_example: {
|
||||
method: "POST",
|
||||
endpoint: `${MCP_API_BASE_URL}/api/mcp/claim_task`,
|
||||
header: "Authorization: Bearer <YOUR_API_KEY>",
|
||||
body: {
|
||||
task_id: "<UUID>",
|
||||
agent_id: "external-gpt-02",
|
||||
developer_wallet: "acct_xxx",
|
||||
},
|
||||
},
|
||||
submit_example: {
|
||||
method: "POST",
|
||||
endpoint: `${MCP_API_BASE_URL}/api/mcp/submit_solution`,
|
||||
header: "Authorization: Bearer <YOUR_API_KEY>",
|
||||
body: {
|
||||
task_id: "<UUID>",
|
||||
claim_token: "<CLAIM_TOKEN>",
|
||||
deliverables: {
|
||||
"README.md": "...",
|
||||
"solution.diff": "...",
|
||||
},
|
||||
},
|
||||
},
|
||||
payload_hints: [
|
||||
"task_id = 開放任務 UUID",
|
||||
"agent_id = 你的穩定識別碼(例如外部 agent name)",
|
||||
"developer_wallet = Stripe Connect acct_xxx 或 EVM 0x 地址",
|
||||
],
|
||||
required_steps: [
|
||||
"1) 先用 curl 或 MCP 列出任務",
|
||||
"2) 規劃 1~3 個 deliverables",
|
||||
"3) 呼叫 claim_task 鎖定任務",
|
||||
"4) 1 小時內完成並 submit_solution",
|
||||
],
|
||||
};
|
||||
|
||||
const actor = resolveExternalActor(request);
|
||||
|
||||
void sendTrafficAlert({
|
||||
@@ -202,10 +289,14 @@ export async function GET(request: Request) {
|
||||
taskId: "open-tasks",
|
||||
message: `External discovery call for open tasks (${publicPayload.length} items)`,
|
||||
metadata: {
|
||||
...trace,
|
||||
source: "public-open-tasks",
|
||||
task_count: publicPayload.length,
|
||||
task_ids: tasks.slice(0, 20).map((task) => task.id),
|
||||
source_ip: sourceIp,
|
||||
user_agent: request.headers.get("user-agent") ?? "unknown",
|
||||
response_status: 200,
|
||||
response_summary: `open_tasks_ok:${publicPayload.length}`,
|
||||
auth_hint: isPublicIp ? "public" : "internal",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,15 +340,20 @@ export async function GET(request: Request) {
|
||||
version: "v1",
|
||||
discovery_mode: "ai-first",
|
||||
beta_program: "VibeWork Beta Zero Friction + 0% Platform Fee for promoted tasks",
|
||||
conversion_hints: conversionHints,
|
||||
tasks: publicPayload,
|
||||
total_open: publicPayload.length,
|
||||
request_id: trace.request_id,
|
||||
last_refreshed_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[open-tasks] Internal error", error);
|
||||
|
||||
const errorName = error?.name ?? "Error";
|
||||
const errorMessage = error?.message ?? "internal_error";
|
||||
|
||||
const actor = resolveExternalActor(request);
|
||||
const msg = error?.message ?? "internal_error";
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "error",
|
||||
action: isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_ERROR" : "INTERNAL_LIST_OPEN_TASKS_ERROR",
|
||||
@@ -265,16 +361,20 @@ export async function GET(request: Request) {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: "open-tasks",
|
||||
message: `open-tasks 查詢失敗: ${msg}`,
|
||||
message: `open-tasks 查詢失敗: ${errorName}: ${errorMessage}`,
|
||||
metadata: {
|
||||
...trace,
|
||||
source: "public-open-tasks",
|
||||
source_ip: sourceIp,
|
||||
user_agent: request.headers.get("user-agent") ?? "unknown",
|
||||
response_status: 500,
|
||||
response_summary: "open_tasks_error",
|
||||
error_name: errorName,
|
||||
error_message: errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error_type: "InternalError", message: msg },
|
||||
{ error_type: "InternalError", error: errorMessage, request_id: trace.request_id },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,149 @@ function normalizedJudgeResult(value: unknown) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeSurface(value: unknown) {
|
||||
if (typeof value !== "string") return "unknown";
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : "unknown";
|
||||
}
|
||||
|
||||
function normalizeSourceIp(value: unknown) {
|
||||
if (typeof value !== "string") return "unknown";
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : "unknown";
|
||||
}
|
||||
|
||||
function normalizeUserAgent(value: unknown) {
|
||||
if (typeof value !== "string") return "unknown";
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "unknown";
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
const tokens = trimmed.split(/[;\s]/).filter((item) => item.length > 0);
|
||||
const topToken = tokens[0] || trimmed;
|
||||
|
||||
if (lower.includes("python-requests")) return "python-requests";
|
||||
if (lower.includes("curl")) return "curl";
|
||||
if (lower.includes("node")) return "node";
|
||||
if (lower.includes("openai")) return "openai";
|
||||
if (lower.includes("perplexity")) return "perplexity";
|
||||
if (lower.includes("anthropic")) return "anthropic";
|
||||
if (lower.includes("bot")) return "bot";
|
||||
|
||||
return topToken.length > 48 ? `${topToken.slice(0, 45)}...` : topToken;
|
||||
}
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"perplexity",
|
||||
"llm",
|
||||
"mcp",
|
||||
"autogpt",
|
||||
"agent",
|
||||
"assistant",
|
||||
"gemini",
|
||||
"cursor",
|
||||
"copilot",
|
||||
];
|
||||
|
||||
function isLikelyAIAgentActor(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (actorType === "AGENT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedActor = (actorId || "").toLowerCase();
|
||||
if (normalizedActor.startsWith("agent:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userAgent = String(metadata?.user_agent || "").toLowerCase();
|
||||
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestHeaders = asRecordJson(metadata?.request_actor_headers);
|
||||
if (!requestHeaders) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerText = Object.values(requestHeaders)
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token));
|
||||
}
|
||||
|
||||
function classifyActorSource(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (actorType === "AGENT" || (actorId || "").toLowerCase().startsWith("agent:")) {
|
||||
return "AGENT";
|
||||
}
|
||||
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
|
||||
return "LIKELY_AI";
|
||||
}
|
||||
return "OTHER";
|
||||
}
|
||||
|
||||
function addCountedBucket(store: Map<string, { events: number; actors: Set<string>; latestAt: number }>, key: string, actorId: string, eventAt: number) {
|
||||
const bucket = store.get(key);
|
||||
if (!bucket) {
|
||||
store.set(key, { events: 1, actors: new Set([actorId]), latestAt: eventAt });
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.events += 1;
|
||||
bucket.actors.add(actorId);
|
||||
if (eventAt > bucket.latestAt) {
|
||||
bucket.latestAt = eventAt;
|
||||
}
|
||||
}
|
||||
|
||||
type ActorSourceBucket = {
|
||||
events: number;
|
||||
actors: Set<string>;
|
||||
latestAt: number;
|
||||
surface?: string;
|
||||
sourceIp?: string;
|
||||
userAgent?: string;
|
||||
};
|
||||
|
||||
const updateActorBucket = (
|
||||
store: Map<string, ActorSourceBucket>,
|
||||
key: string,
|
||||
actorId: string,
|
||||
eventAt: number,
|
||||
keyField: Record<string, string>
|
||||
) => {
|
||||
const bucket = store.get(key);
|
||||
if (!bucket) {
|
||||
store.set(key, {
|
||||
...keyField,
|
||||
events: 1,
|
||||
actors: new Set([actorId]),
|
||||
latestAt: eventAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.events += 1;
|
||||
bucket.actors.add(actorId);
|
||||
if (eventAt > bucket.latestAt) {
|
||||
bucket.latestAt = eventAt;
|
||||
}
|
||||
};
|
||||
|
||||
function isMissingTableError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -257,9 +400,75 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: event.createdAt,
|
||||
surface: metadata?.surface,
|
||||
level: metadata?.level,
|
||||
actorSource: classifyActorSource(event.actorType, event.actorId, metadata),
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
||||
const externalSourceSurfaceMap = new Map<string, { surface: string; events: number; actors: Set<string>; latestAt: number }>();
|
||||
const externalIpSurfaceMap = new Map<string, { sourceIp: string; events: number; actors: Set<string>; latestAt: number }>();
|
||||
const externalUserAgentMap = new Map<string, { userAgent: string; events: number; actors: Set<string>; latestAt: number }>();
|
||||
const responseStatusSummary = new Map<string, { responseStatus: string; events: number; actors: Set<string>; latestAt: number }>();
|
||||
const externalErrorRows: Array<{
|
||||
actor_id: string;
|
||||
actor_type: string;
|
||||
action: string;
|
||||
task_id: string;
|
||||
surface: string;
|
||||
source_ip: string;
|
||||
user_agent: string;
|
||||
response_status: number | null;
|
||||
error_name: string;
|
||||
error_message: string;
|
||||
created_at_ms: number;
|
||||
}> = [];
|
||||
|
||||
recentEvents.forEach((event) => {
|
||||
const actorId = event.actorId || "agent:unknown";
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const normalizedSurface = normalizeSurface(event.surface);
|
||||
const normalizedIp = normalizeSourceIp(metadata?.source_ip);
|
||||
const normalizedUa = normalizeUserAgent(metadata?.user_agent);
|
||||
const isExternalAgent = event.action.startsWith("EXTERNAL_") &&
|
||||
event.actorType === "AGENT" &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId });
|
||||
|
||||
if (!isExternalAgent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventAt = event.createdAt.getTime();
|
||||
const responseStatus = typeof metadata?.response_status === "number" ? metadata.response_status : null;
|
||||
const errorName = typeof metadata?.error_name === "string" ? metadata.error_name : "";
|
||||
const errorMessage = typeof metadata?.error_message === "string" ? metadata.error_message : "";
|
||||
const taskId = typeof metadata?.task_id === "string" ? metadata.task_id : (event.entityId || "-");
|
||||
|
||||
updateActorBucket(externalSourceSurfaceMap, normalizedSurface, actorId, eventAt, { surface: normalizedSurface });
|
||||
updateActorBucket(externalIpSurfaceMap, normalizedIp, actorId, eventAt, { sourceIp: normalizedIp });
|
||||
updateActorBucket(externalUserAgentMap, normalizedUa, actorId, eventAt, { userAgent: normalizedUa });
|
||||
addCountedBucket(responseStatusSummary, String(responseStatus ?? "n/a"), actorId, eventAt);
|
||||
|
||||
if (
|
||||
event.action.includes("ERROR") ||
|
||||
event.action.includes("FORBIDDEN") ||
|
||||
event.action.includes("MISSING")
|
||||
) {
|
||||
externalErrorRows.push({
|
||||
actor_id: actorId,
|
||||
actor_type: event.actorType || "USER",
|
||||
action: event.action,
|
||||
task_id: taskId,
|
||||
surface: normalizedSurface,
|
||||
source_ip: normalizedIp,
|
||||
user_agent: normalizedUa,
|
||||
response_status: responseStatus,
|
||||
error_name: errorName || "unknown",
|
||||
error_message: errorMessage || "unknown",
|
||||
created_at_ms: eventAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const recentExternalEvents = recentEvents.filter((event) =>
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({
|
||||
@@ -272,6 +481,46 @@ export async function GET(request: NextRequest) {
|
||||
(event) => !event.action.startsWith("EXTERNAL_")
|
||||
);
|
||||
|
||||
const externalSurfaceSummary = Array.from(externalSourceSurfaceMap.entries())
|
||||
.map(([surface, bucket]) => ({
|
||||
surface,
|
||||
events: bucket.events,
|
||||
actors: bucket.actors.size,
|
||||
latest_at: bucket.latestAt,
|
||||
}))
|
||||
.sort((a, b) => b.events - a.events);
|
||||
|
||||
const externalSourceIpSummary = Array.from(externalIpSurfaceMap.entries())
|
||||
.map(([sourceIp, bucket]) => ({
|
||||
source_ip: sourceIp,
|
||||
events: bucket.events,
|
||||
actors: bucket.actors.size,
|
||||
latest_at: bucket.latestAt,
|
||||
}))
|
||||
.sort((a, b) => b.events - a.events);
|
||||
|
||||
const externalUserAgentSummary = Array.from(externalUserAgentMap.entries())
|
||||
.map(([userAgent, bucket]) => ({
|
||||
user_agent: userAgent,
|
||||
events: bucket.events,
|
||||
actors: bucket.actors.size,
|
||||
latest_at: bucket.latestAt,
|
||||
}))
|
||||
.sort((a, b) => b.events - a.events);
|
||||
|
||||
const externalResponseStatusSummary = Array.from(responseStatusSummary.entries())
|
||||
.map(([responseStatus, bucket]) => ({
|
||||
response_status: responseStatus,
|
||||
events: bucket.events,
|
||||
actors: bucket.actors.size,
|
||||
latest_at: bucket.latestAt,
|
||||
}))
|
||||
.sort((a, b) => b.events - a.events);
|
||||
|
||||
const externalErrorRowsSorted = externalErrorRows
|
||||
.sort((left, right) => right.created_at_ms - left.created_at_ms)
|
||||
.slice(0, 30);
|
||||
|
||||
return NextResponse.json({
|
||||
period_minutes: minutes,
|
||||
total_events: totalRows,
|
||||
@@ -283,6 +532,11 @@ export async function GET(request: NextRequest) {
|
||||
conversion_rates: conversionRates,
|
||||
external_event_types: externalEventTypes,
|
||||
internal_event_types: internalEventTypes,
|
||||
external_surface_summary: externalSurfaceSummary,
|
||||
external_source_ip_summary: externalSourceIpSummary,
|
||||
external_user_agent_summary: externalUserAgentSummary,
|
||||
external_response_status_summary: externalResponseStatusSummary,
|
||||
external_error_rows: externalErrorRowsSorted,
|
||||
recent_external_events: recentExternalEvents,
|
||||
recent_internal_events: recentInternalEvents,
|
||||
updated_at: new Date().toISOString(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,22 @@ export type TrafficAlertEvent = {
|
||||
actorId: string;
|
||||
taskId?: string;
|
||||
message: string;
|
||||
sourceIp?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const TRAFFIC_WEBHOOK_URL = process.env.VIBEWORK_TRAFFIC_WEBHOOK_URL?.trim();
|
||||
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN?.trim();
|
||||
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID?.trim();
|
||||
const TELEGRAM_BOT_TOKEN = (
|
||||
process.env.TELEGRAM_BOT_TOKEN ||
|
||||
process.env.VIBEWORKAIAGENTBOT_TOKEN ||
|
||||
process.env.VIBEWORK_AI_BOT_TOKEN
|
||||
)?.trim();
|
||||
const TELEGRAM_CHAT_ID = (
|
||||
process.env.TELEGRAM_CHAT_ID ||
|
||||
process.env.VIBEWORKAIAGENTBOT_CHAT_ID ||
|
||||
process.env.VIBEWORK_AI_BOT_CHAT_ID
|
||||
)?.trim();
|
||||
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL?.trim();
|
||||
|
||||
function escapeMarkdown(value: unknown) {
|
||||
@@ -33,7 +43,11 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
|
||||
`\n- 行為: \`${event.action}\`` +
|
||||
`\n- 通道: \`${event.surface}\`` +
|
||||
`\n- Actor: \`${event.actorType}/${event.actorId}\`` +
|
||||
`\n- Source IP: \`${event.sourceIp || "n/a"}\`` +
|
||||
`\n- User-Agent: \`${event.userAgent || "n/a"}\`` +
|
||||
`\n- 回應: \`${typeof event.metadata?.response_status === "number" ? event.metadata.response_status : "n/a"}\`` +
|
||||
`\n- 任務: \`${event.taskId || "n/a"}\`` +
|
||||
`\n- request_id: \`${typeof event.metadata?.request_id === "string" ? event.metadata.request_id : "n/a"}\`` +
|
||||
`\n- 訊息: ${escapeMarkdown(event.message)}`
|
||||
);
|
||||
}
|
||||
@@ -124,10 +138,18 @@ async function writeTrafficAuditEvent(event: TrafficAlertEvent) {
|
||||
|
||||
export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void> {
|
||||
void writeTrafficAuditEvent(event);
|
||||
const eventSourceIp =
|
||||
event.sourceIp ??
|
||||
(typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined);
|
||||
const eventUserAgent =
|
||||
event.userAgent ??
|
||||
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined);
|
||||
|
||||
const payload = {
|
||||
platform: "agent-bounty-protocol",
|
||||
created_at: new Date().toISOString(),
|
||||
source_ip: eventSourceIp,
|
||||
user_agent: eventUserAgent,
|
||||
...event,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ type FunnelSummary = {
|
||||
submitEvents: number;
|
||||
judgePassEvents: number;
|
||||
judgeFailEvents: number;
|
||||
externalOpenedActors: number;
|
||||
externalClaimingActors: number;
|
||||
externalSubmittingActors: number;
|
||||
externalOnlyOpenActors: number;
|
||||
topOpenOnlyActors: Array<{ actorId: string; opens: number }>;
|
||||
payoutCaptured: number;
|
||||
payoutReleased: number;
|
||||
periodMinutes: number;
|
||||
@@ -35,6 +40,31 @@ function normalizedJudgeResult(value: unknown) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isInternalActorId(actorId: string | null | undefined) {
|
||||
if (!actorId) return true;
|
||||
const actorIdValue = actorId.toLowerCase();
|
||||
if (actorIdValue === "unknown" || actorIdValue === "mcp-anonymous") return true;
|
||||
|
||||
const ipMatch = actorIdValue.match(/^open-tasks:([a-z0-9.:_-]+)$/);
|
||||
if (!ipMatch?.[1]) return false;
|
||||
|
||||
const actorIp = ipMatch[1];
|
||||
if (
|
||||
actorIp.startsWith("127.") ||
|
||||
actorIp.startsWith("10.") ||
|
||||
actorIp.startsWith("192.168.")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actorIp.startsWith("172.")) {
|
||||
const secondOctet = Number(actorIp.split(".")[1]);
|
||||
return secondOctet >= 16 && secondOctet <= 31;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMissingLedgerTableError(error: unknown) {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -47,24 +77,36 @@ function isMissingLedgerTableError(error: unknown) {
|
||||
async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
const since = new Date(Date.now() - minutes * 60 * 1000);
|
||||
|
||||
const summaryRows = await prisma.auditEvent.groupBy({
|
||||
by: ["action"],
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
action: {
|
||||
startsWith: "EXTERNAL_",
|
||||
const [summaryRows, actorRows, judgeRows] = await Promise.all([
|
||||
prisma.auditEvent.groupBy({
|
||||
by: ["action"],
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
action: {
|
||||
startsWith: "EXTERNAL_",
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const judgeRows = await prisma.auditEvent.findMany({
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
action: "JUDGE_COMPLETE",
|
||||
},
|
||||
select: { metadata: true },
|
||||
});
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.auditEvent.groupBy({
|
||||
by: ["actorId", "action"],
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
actorType: "AGENT",
|
||||
action: {
|
||||
startsWith: "EXTERNAL_",
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.auditEvent.findMany({
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
action: "JUDGE_COMPLETE",
|
||||
},
|
||||
select: { metadata: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
let payoutCaptured = 0;
|
||||
let payoutReleased = 0;
|
||||
@@ -111,12 +153,57 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "fail";
|
||||
}).length;
|
||||
|
||||
const actorMap = new Map<
|
||||
string,
|
||||
{ actorId: string; opens: number; claims: number; submits: number }
|
||||
>();
|
||||
actorRows.forEach((row) => {
|
||||
const actorId = row.actorId || "agent:unknown";
|
||||
if (isInternalActorId(actorId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const flow = actorMap.get(actorId) || {
|
||||
actorId,
|
||||
opens: 0,
|
||||
claims: 0,
|
||||
submits: 0,
|
||||
};
|
||||
|
||||
if (row.action === "EXTERNAL_LIST_OPEN_TASKS" || row.action === "EXTERNAL_LIST_OPEN_TASKS_MCP") {
|
||||
flow.opens += row._count._all;
|
||||
} else if (row.action === "EXTERNAL_CLAIM_TASK_SUCCESS") {
|
||||
flow.claims += row._count._all;
|
||||
} else if (row.action === "EXTERNAL_SUBMIT_SOLUTION_SUCCESS") {
|
||||
flow.submits += row._count._all;
|
||||
}
|
||||
|
||||
actorMap.set(actorId, flow);
|
||||
});
|
||||
|
||||
const externalOpenedActors = actorMap.size;
|
||||
const externalClaimingActors = Array.from(actorMap.values()).filter((item) => item.claims > 0).length;
|
||||
const externalSubmittingActors = Array.from(actorMap.values()).filter((item) => item.submits > 0).length;
|
||||
const openOnlyActors = Array.from(actorMap.values())
|
||||
.filter((item) => item.opens > 0 && item.claims === 0)
|
||||
.sort((left, right) => right.opens - left.opens);
|
||||
const externalOnlyOpenActors = openOnlyActors.length;
|
||||
const topOpenOnlyActors = openOnlyActors.slice(0, 3).map((item) => ({
|
||||
actorId: item.actorId,
|
||||
opens: item.opens,
|
||||
}));
|
||||
|
||||
return {
|
||||
discoveryEvents,
|
||||
claimEvents,
|
||||
submitEvents,
|
||||
judgePassEvents,
|
||||
judgeFailEvents,
|
||||
externalOpenedActors,
|
||||
externalClaimingActors,
|
||||
externalSubmittingActors,
|
||||
externalOnlyOpenActors,
|
||||
topOpenOnlyActors,
|
||||
payoutCaptured,
|
||||
payoutReleased,
|
||||
periodMinutes: minutes,
|
||||
@@ -124,7 +211,19 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
}
|
||||
|
||||
function buildAlertMessage(rule: string, summary: FunnelSummary) {
|
||||
const { periodMinutes, discoveryEvents, claimEvents, submitEvents, judgePassEvents, payoutCaptured } = summary;
|
||||
const {
|
||||
periodMinutes,
|
||||
discoveryEvents,
|
||||
claimEvents,
|
||||
submitEvents,
|
||||
judgePassEvents,
|
||||
payoutCaptured,
|
||||
externalOpenedActors,
|
||||
externalClaimingActors,
|
||||
externalSubmittingActors,
|
||||
externalOnlyOpenActors,
|
||||
topOpenOnlyActors,
|
||||
} = summary;
|
||||
|
||||
switch (rule) {
|
||||
case "EXTERNAL_FUNNEL_CLAIM_STALL":
|
||||
@@ -135,6 +234,12 @@ function buildAlertMessage(rule: string, summary: FunnelSummary) {
|
||||
return `外部已提交 ${submitEvents} 次但尚無 PASS(JUDGE_RESULT PASS = ${judgePassEvents})。請先檢查 task acceptance_criteria 與測試欄位是否可自動驗證。`;
|
||||
case "EXTERNAL_FUNNEL_PAYOUT_STALL":
|
||||
return `有 PASS 但未收款(payout CAPTURE 成功 = ${payoutCaptured})。請確認支付授權、Stripe key 與 capture 任務是否正常。`;
|
||||
case "EXTERNAL_FUNNEL_OPEN_COLD_STANDBY":
|
||||
return `最近 ${periodMinutes} 分鐘觀測到 ${discoveryEvents} 次外部曝光,` +
|
||||
`外部 Actor= ${externalOpenedActors} 位,` +
|
||||
`已接案=${externalClaimingActors}、已提交=${externalSubmittingActors},` +
|
||||
`仍停在曝光僅曝光階段 ${externalOnlyOpenActors} 位。` +
|
||||
`${topOpenOnlyActors.length ? `先看未進一步的熱門 Actor:${topOpenOnlyActors.map((actor) => `${actor.actorId}(${actor.opens})`).join(", ")}。` : ""}`;
|
||||
default:
|
||||
return "外部 AI 流量轉化斷崖異常。";
|
||||
}
|
||||
@@ -150,6 +255,13 @@ function alertRules(summary: FunnelSummary): Array<{ action: string; message: st
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.externalOnlyOpenActors >= 3 && summary.discoveryEvents >= 10) {
|
||||
alerts.push({
|
||||
action: "EXTERNAL_FUNNEL_OPEN_COLD_STANDBY",
|
||||
message: buildAlertMessage("EXTERNAL_FUNNEL_OPEN_COLD_STANDBY", summary),
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.claimEvents > 0 && summary.submitEvents === 0) {
|
||||
alerts.push({
|
||||
action: "EXTERNAL_FUNNEL_SUBMIT_STALL",
|
||||
@@ -210,9 +322,14 @@ export async function evaluateExternalFunnelHealth(input: MonitorInput): Promise
|
||||
submit_events: summary.submitEvents,
|
||||
judge_pass_events: summary.judgePassEvents,
|
||||
judge_fail_events: summary.judgeFailEvents,
|
||||
external_opened_actors: summary.externalOpenedActors,
|
||||
external_claiming_actors: summary.externalClaimingActors,
|
||||
external_submitting_actors: summary.externalSubmittingActors,
|
||||
external_only_open_actors: summary.externalOnlyOpenActors,
|
||||
payout_captured: summary.payoutCaptured,
|
||||
payout_released: summary.payoutReleased,
|
||||
period_minutes: summary.periodMinutes,
|
||||
top_open_only_actors: summary.topOpenOnlyActors,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user