feat: expand external traffic observability and alert details
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s

This commit is contained in:
OG T
2026-06-07 19:16:41 +08:00
parent 4fd89a447e
commit 362bbc3506
7 changed files with 1744 additions and 49 deletions

View File

@@ -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.

View File

@@ -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 });
}
}

View File

@@ -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_KEYMCP 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 }
);
}

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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} 次但尚無 PASSJUDGE_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,
},
});
})