chore: open conversion flow + disable scout import noise
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 8s
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 8s
This commit is contained in:
@@ -20,6 +20,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 MCP_AGENT_HEADERS = [
|
||||
"x-agent-id",
|
||||
@@ -64,6 +65,52 @@ function resolveSourceIp(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureBuilderAgent(agentId: string) {
|
||||
const existingAgent = await prisma.agentProfile.findUnique({ where: { agent_id: agentId } });
|
||||
if (existingAgent) {
|
||||
return existingAgent;
|
||||
}
|
||||
|
||||
if (!AUTO_WHITELIST_EXTERNAL_AGENTS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await prisma.agentProfile.create({
|
||||
data: {
|
||||
agent_id: agentId,
|
||||
type: "BUILDER",
|
||||
status: "WHITELISTED",
|
||||
},
|
||||
});
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_AGENT_AUTO_WHITELIST",
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: agentId,
|
||||
message: `外部 Agent 首次接案已自動白名單: ${agentId}`,
|
||||
metadata: { agent_id: agentId },
|
||||
});
|
||||
|
||||
return created;
|
||||
} catch (error: unknown) {
|
||||
// In high-concurrency case, another request may create the same agent first.
|
||||
const isUniqueViolation =
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "P2002";
|
||||
|
||||
if (isUniqueViolation) {
|
||||
return prisma.agentProfile.findUnique({ where: { agent_id: agentId } });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateIp(ip: string | undefined) {
|
||||
if (!ip) return true;
|
||||
const normalized = ip.trim().toLowerCase();
|
||||
@@ -227,10 +274,24 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
const parsed = ClaimTaskRequestSchema.parse(body);
|
||||
|
||||
// Verify Agent Whitelist
|
||||
const agent = await prisma.agentProfile.findUnique({
|
||||
where: { agent_id: parsed.agent_id }
|
||||
});
|
||||
if (!agent || agent.status !== "WHITELISTED") {
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id);
|
||||
if (!agent) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN",
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: parsed.agent_id,
|
||||
taskId: parsed.task_id,
|
||||
message: `外部 Agent 嘗試接案但尚未白名單: ${parsed.agent_id}`,
|
||||
metadata: {
|
||||
developer_wallet: parsed.developer_wallet,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (agent.status !== "WHITELISTED") {
|
||||
return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -286,7 +347,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
action: "EXTERNAL_CLAIM_TASK_SUCCESS",
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: parsed.developer_wallet,
|
||||
actorId: parsed.agent_id,
|
||||
taskId: claim.task_id,
|
||||
message: `Agent 成功接案: ${parsed.task_id}`,
|
||||
metadata: {
|
||||
|
||||
@@ -146,110 +146,136 @@ export async function GET(request: Request) {
|
||||
const isPublicIp = !isPrivateIp(sourceIp);
|
||||
const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS" : "INTERNAL_LIST_OPEN_TASKS";
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
status: TaskStatus.OPEN,
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
try {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
status: TaskStatus.OPEN,
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
reward_amount: true,
|
||||
reward_currency: true,
|
||||
required_stack: true,
|
||||
status: true,
|
||||
difficulty: true,
|
||||
scope_clarity_score: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
scout_id: true,
|
||||
stripe_checkout_session_id: true,
|
||||
stripe_payment_intent_id: true,
|
||||
},
|
||||
});
|
||||
orderBy: { created_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
reward_amount: true,
|
||||
reward_currency: true,
|
||||
required_stack: true,
|
||||
status: true,
|
||||
difficulty: true,
|
||||
scope_clarity_score: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
scout_id: true,
|
||||
stripe_checkout_session_id: true,
|
||||
stripe_payment_intent_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const publicPayload = tasks.map((task) => ({
|
||||
task_id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
difficulty: task.difficulty,
|
||||
reward_amount_cents: task.reward_amount,
|
||||
reward_display: `$${(task.reward_amount / 100).toFixed(2)} ${task.reward_currency}`,
|
||||
required_stack: task.required_stack,
|
||||
scope_clarity_score: task.scope_clarity_score,
|
||||
created_at: task.created_at.toISOString(),
|
||||
updated_at: task.updated_at.toISOString(),
|
||||
source: task.scout_id ? "scout" : "human",
|
||||
payout_mode: getPayoutMode(task),
|
||||
task_url: `https://agent.wooo.work/tasks/${task.id}`,
|
||||
}));
|
||||
const publicPayload = tasks.map((task) => ({
|
||||
task_id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
difficulty: task.difficulty,
|
||||
reward_amount_cents: task.reward_amount,
|
||||
reward_display: `$${(task.reward_amount / 100).toFixed(2)} ${task.reward_currency}`,
|
||||
required_stack: task.required_stack,
|
||||
scope_clarity_score: task.scope_clarity_score,
|
||||
created_at: task.created_at.toISOString(),
|
||||
updated_at: task.updated_at.toISOString(),
|
||||
source: task.scout_id ? "scout" : "human",
|
||||
payout_mode: getPayoutMode(task),
|
||||
task_url: `https://agent.wooo.work/tasks/${task.id}`,
|
||||
}));
|
||||
|
||||
const actor = resolveExternalActor(request);
|
||||
const actor = resolveExternalActor(request);
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: trafficAction,
|
||||
surface: "public-open-tasks",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: "open-tasks",
|
||||
message: `External discovery call for open tasks (${publicPayload.length} items)`,
|
||||
metadata: {
|
||||
source: "public-open-tasks",
|
||||
task_count: publicPayload.length,
|
||||
source_ip: sourceIp,
|
||||
user_agent: request.headers.get("user-agent") ?? "unknown",
|
||||
},
|
||||
});
|
||||
|
||||
// Light-weight surge signal: when this endpoint is hit in large bursts,
|
||||
// emit a warning once every 25 requests in a 10-minute window.
|
||||
const surgeWindow = 10;
|
||||
const surgeWindowStart = new Date(Date.now() - surgeWindow * 60 * 1000);
|
||||
void prisma.auditEvent.count({
|
||||
where: {
|
||||
createdAt: { gte: surgeWindowStart },
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: trafficAction,
|
||||
},
|
||||
}).then((eventCount) => {
|
||||
if (eventCount > 0 && eventCount % 25 === 0) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_SURGE" : "INTERNAL_LIST_OPEN_TASKS_SURGE",
|
||||
surface: "public-open-tasks",
|
||||
actorType: "SYSTEM",
|
||||
actorId: "traffic-monitor",
|
||||
taskId: "open-tasks",
|
||||
message: `Open tasks discovery surge detected: ${eventCount} hits in ${surgeWindow}m`,
|
||||
metadata: {
|
||||
alert_window_minutes: surgeWindow,
|
||||
event_count: eventCount,
|
||||
surface: "public-open-tasks",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: "open-tasks",
|
||||
message: `External discovery call for open tasks (${publicPayload.length} items)`,
|
||||
metadata: {
|
||||
source: "public-open-tasks",
|
||||
task_count: publicPayload.length,
|
||||
source_ip: sourceIp,
|
||||
user_agent: request.headers.get("user-agent") ?? "unknown",
|
||||
},
|
||||
});
|
||||
|
||||
// Light-weight surge signal: when this endpoint is hit in large bursts,
|
||||
// emit a warning once every 25 requests in a 10-minute window.
|
||||
const surgeWindow = 10;
|
||||
const surgeWindowStart = new Date(Date.now() - surgeWindow * 60 * 1000);
|
||||
void prisma.auditEvent.count({
|
||||
where: {
|
||||
createdAt: { gte: surgeWindowStart },
|
||||
action: trafficAction,
|
||||
},
|
||||
}).then((eventCount) => {
|
||||
if (eventCount > 0 && eventCount % 25 === 0) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_SURGE" : "INTERNAL_LIST_OPEN_TASKS_SURGE",
|
||||
surface: "public-open-tasks",
|
||||
},
|
||||
actorType: "SYSTEM",
|
||||
actorId: "traffic-monitor",
|
||||
taskId: "open-tasks",
|
||||
message: `Open tasks discovery surge detected: ${eventCount} hits in ${surgeWindow}m`,
|
||||
metadata: {
|
||||
alert_window_minutes: surgeWindow,
|
||||
event_count: eventCount,
|
||||
surface: "public-open-tasks",
|
||||
},
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
if (isPublicIp) {
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "public-open-tasks",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
if (isPublicIp) {
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "public-open-tasks",
|
||||
periodMinutes: 10,
|
||||
return NextResponse.json({
|
||||
platform: "VibeWork",
|
||||
version: "v1",
|
||||
discovery_mode: "ai-first",
|
||||
beta_program: "VibeWork Beta Zero Friction + 0% Platform Fee for promoted tasks",
|
||||
tasks: publicPayload,
|
||||
total_open: publicPayload.length,
|
||||
last_refreshed_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[open-tasks] Internal error", error);
|
||||
|
||||
return NextResponse.json({
|
||||
platform: "VibeWork",
|
||||
version: "v1",
|
||||
discovery_mode: "ai-first",
|
||||
beta_program: "VibeWork Beta Zero Friction + 0% Platform Fee for promoted tasks",
|
||||
tasks: publicPayload,
|
||||
total_open: publicPayload.length,
|
||||
last_refreshed_at: new Date().toISOString(),
|
||||
});
|
||||
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",
|
||||
surface: "public-open-tasks",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
taskId: "open-tasks",
|
||||
message: `open-tasks 查詢失敗: ${msg}`,
|
||||
metadata: {
|
||||
source: "public-open-tasks",
|
||||
source_ip: sourceIp,
|
||||
user_agent: request.headers.get("user-agent") ?? "unknown",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error_type: "InternalError", message: msg },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ function asRecordJson(value: unknown): Record<string, unknown> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizedJudgeResult(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isMissingTableError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -199,11 +206,11 @@ export async function GET(request: NextRequest) {
|
||||
const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
|
||||
const judgePassEvents = judgeCompleteRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return metadata?.overall_result === "PASS";
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "pass";
|
||||
}).length;
|
||||
const judgeFailEvents = judgeCompleteRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return metadata?.overall_result === "FAIL";
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "fail";
|
||||
}).length;
|
||||
|
||||
const conversionRate = (numerator: number, denominator: number) => {
|
||||
|
||||
@@ -105,7 +105,7 @@ export default async function Home() {
|
||||
{`curl https://agent.wooo.work/api/open-tasks`}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
※ 第一階段僅開放白名單 Agent 接案。若需申請白名單,請聯絡管理員。
|
||||
※ 目前採用「首次接案自動白名單」模式,已可直接驗證真實外部 AI 的接案流程。
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/traffic" className="inline-flex items-center gap-2 text-emerald-400 hover:text-emerald-300">
|
||||
|
||||
@@ -31,6 +31,13 @@ function asRecordJson(value: unknown): Record<string, unknown> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizedJudgeResult(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function percent(numerator: number, denominator: number) {
|
||||
if (!denominator) return 0;
|
||||
return Math.round((numerator / denominator) * 1000) / 10;
|
||||
@@ -217,11 +224,11 @@ async function getTrafficSummary(minutes: number) {
|
||||
const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
|
||||
const judgePassEvents = judgeCompleteRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return metadata?.overall_result === "PASS";
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "pass";
|
||||
}).length;
|
||||
const judgeFailEvents = judgeCompleteRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return metadata?.overall_result === "FAIL";
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "fail";
|
||||
}).length;
|
||||
|
||||
const conversionSummary = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { request } from "node:https";
|
||||
import { prisma } from "./prisma";
|
||||
import { Prisma } from "../../prisma/generated/client";
|
||||
|
||||
@@ -37,6 +38,52 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
|
||||
);
|
||||
}
|
||||
|
||||
async function sendViaHttps(url: string, body: Record<string, unknown>) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const payload = JSON.stringify(body);
|
||||
|
||||
const requestHandle = request(
|
||||
{
|
||||
method: "POST",
|
||||
hostname: parsed.hostname,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
port: 443,
|
||||
protocol: "https:",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
let responseBody = "";
|
||||
response.on("data", (chunk) => {
|
||||
responseBody += chunk;
|
||||
});
|
||||
|
||||
response.on("end", () => {
|
||||
if (response.statusCode && response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
reject(new Error(`Telegram API ${response.statusCode}: ${responseBody.slice(0, 200)}`));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
requestHandle.on("error", reject);
|
||||
requestHandle.setTimeout(3000, () => {
|
||||
requestHandle.destroy(new Error("Telegram request timeout"));
|
||||
});
|
||||
requestHandle.write(payload);
|
||||
requestHandle.end();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEntityFromTrafficEvent(event: TrafficAlertEvent) {
|
||||
if (event.taskId) {
|
||||
return { entityType: "TASK", entityId: event.taskId };
|
||||
@@ -118,11 +165,11 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: TELEGRAM_CHAT_ID,
|
||||
text: buildTelegramMessage(event),
|
||||
parse_mode: "MarkdownV2",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
chat_id: TELEGRAM_CHAT_ID,
|
||||
text: buildTelegramMessage(event),
|
||||
parse_mode: "MarkdownV2",
|
||||
}),
|
||||
},
|
||||
},
|
||||
].filter(Boolean) as Array<{ kind: string; url: string; init: RequestInit }>;
|
||||
@@ -132,11 +179,18 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
|
||||
await Promise.allSettled(
|
||||
notifyTargets.map(async (target) => {
|
||||
try {
|
||||
const response = await fetch(target.url, { ...target.init, signal: AbortSignal.timeout(3000) });
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[Traffic alert notify failed] ${target.kind} ${response.status} ${await response.text()}`
|
||||
);
|
||||
if (target.kind === "telegram") {
|
||||
const payload = target.init.body
|
||||
? JSON.parse(typeof target.init.body === "string" ? target.init.body : "{}")
|
||||
: {};
|
||||
await sendViaHttps(target.url, payload);
|
||||
} else {
|
||||
const response = await fetch(target.url, { ...target.init, signal: AbortSignal.timeout(3000) });
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[Traffic alert notify failed] ${target.kind} ${response.status} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Traffic alert notify failed] ${target.kind}`, error);
|
||||
|
||||
@@ -28,6 +28,13 @@ function asRecordJson(value: unknown): Record<string, unknown> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizedJudgeResult(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isMissingLedgerTableError(error: unknown) {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -96,12 +103,12 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
|
||||
const judgePassEvents = judgeRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return metadata?.overall_result === "PASS";
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "pass";
|
||||
}).length;
|
||||
|
||||
const judgeFailEvents = judgeRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return metadata?.overall_result === "FAIL";
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "fail";
|
||||
}).length;
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,9 @@ services:
|
||||
- VIBEWORK_API_URL=http://agent_bounty_web:3000/api
|
||||
- SCOUT_API_KEY=${SCOUT_API_KEY:-dev_scout_key}
|
||||
- SCOUT_AGENT_ID=scout_official_1
|
||||
- SCOUT_ENABLED=${SCOUT_ENABLED:-false}
|
||||
- SCOUT_PER_PAGE=${SCOUT_PER_PAGE:-80}
|
||||
- SCOUT_MAX_ISSUES_PER_SCAN=${SCOUT_MAX_ISSUES_PER_SCAN:-30}
|
||||
networks:
|
||||
- agent-bounty-network
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ services:
|
||||
- NODE_ENV=production
|
||||
- API_KEY=${API_KEY:-super-secret-mcp-key}
|
||||
- E2B_API_KEY=${E2B_API_KEY:-""}
|
||||
- AUTO_WHITELIST_EXTERNAL_AGENTS=${AUTO_WHITELIST_EXTERNAL_AGENTS:-true}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||
- TRAFFIC_MONITOR_TOKEN=${TRAFFIC_MONITOR_TOKEN:-}
|
||||
- VIBEWORK_TRAFFIC_WEBHOOK_URL=${VIBEWORK_TRAFFIC_WEBHOOK_URL:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -72,7 +77,7 @@ services:
|
||||
# Keep compatibility with existing naming
|
||||
- SCOUT_ISSUE_LABEL=${SCOUT_ISSUE_LABEL:-}
|
||||
- SCOUT_CRON_EXPRESSION=${SCOUT_CRON_EXPRESSION:-*/1 * * * *}
|
||||
- SCOUT_ENABLED=${SCOUT_ENABLED:-true}
|
||||
- SCOUT_ENABLED=${SCOUT_ENABLED:-false}
|
||||
- SCOUT_PER_PAGE=${SCOUT_PER_PAGE:-80}
|
||||
- SCOUT_MAX_ISSUES_PER_SCAN=${SCOUT_MAX_ISSUES_PER_SCAN:-90}
|
||||
# GitHub token should be provided in deployment env for real posting.
|
||||
|
||||
136
scripts/runtime/purge-github-issue-tasks.ts
Normal file
136
scripts/runtime/purge-github-issue-tasks.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { PrismaClient } from "../../apps/web/prisma/generated/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const TARGET_PREFIX = process.env.GITHUB_ISSUE_PREFIX || "GitHub Issue:";
|
||||
const BATCH_SIZE = Math.max(parseInt(process.env.PURGE_BATCH_SIZE || "100", 10), 1);
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const isDryRun = args.has("--dry-run");
|
||||
const forceRun = args.has("--force");
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const output: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
output.push(items.slice(index, index + size));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeJudgeCount(value: unknown): number {
|
||||
return typeof value === "number" ? value : 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!isDryRun && !forceRun) {
|
||||
console.error(
|
||||
"Refuse to run without explicit mode. Use --dry-run 先看影響筆數,或加上 --force 正式刪除。"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
title: {
|
||||
startsWith: TARGET_PREFIX,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const targetTaskIds = targetTasks.map((task) => task.id);
|
||||
|
||||
if (targetTaskIds.length === 0) {
|
||||
console.log(`No task with prefix "${TARGET_PREFIX}" found. done.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const submissionIdsForAll = (
|
||||
await prisma.submission.findMany({
|
||||
where: { task_id: { in: targetTaskIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((row) => row.id);
|
||||
|
||||
const [taskCount, submissionCount, claimCount, judgeCount, ledgerCount, auditCount, scoutDraftEvents] =
|
||||
await Promise.all([
|
||||
prisma.task.count({ where: { id: { in: targetTaskIds } } }),
|
||||
prisma.submission.count({ where: { task_id: { in: targetTaskIds } } }),
|
||||
prisma.claim.count({ where: { task_id: { in: targetTaskIds } } }),
|
||||
submissionIdsForAll.length
|
||||
? prisma.judgeResult.count({ where: { submission_id: { in: submissionIdsForAll } } })
|
||||
: Promise.resolve(0),
|
||||
prisma.ledgerEntry.count({ where: { task_id: { in: targetTaskIds } } }),
|
||||
prisma.auditEvent.count({ where: { entityType: "TASK", entityId: { in: targetTaskIds } } }),
|
||||
prisma.auditEvent.count({ where: { action: { startsWith: "SCOUT_DRAFT_" } } }),
|
||||
]);
|
||||
|
||||
console.log("=== Purge Impact Preview ===");
|
||||
console.log(`Task count: ${normalizeJudgeCount(taskCount)}`);
|
||||
console.log(`Submission count: ${normalizeJudgeCount(submissionCount)}`);
|
||||
console.log(`Claim count: ${normalizeJudgeCount(claimCount)}`);
|
||||
console.log(`JudgeResult count: ${normalizeJudgeCount(judgeCount)}`);
|
||||
console.log(`LedgerEntry count: ${normalizeJudgeCount(ledgerCount)}`);
|
||||
console.log(`AuditEvent (entityType TASK) count: ${normalizeJudgeCount(auditCount)}`);
|
||||
console.log(`AuditEvent (SCOUT_DRAFT_*) count: ${normalizeJudgeCount(scoutDraftEvents)}`);
|
||||
console.log(`Target batch size: ${BATCH_SIZE}`);
|
||||
|
||||
if (isDryRun) {
|
||||
console.log("Dry-run complete. No data was modified.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const idBatch of chunk(targetTaskIds, BATCH_SIZE)) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const batchSubmissionIds = (
|
||||
await tx.submission.findMany({
|
||||
where: { task_id: { in: idBatch } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((row) => row.id);
|
||||
|
||||
if (batchSubmissionIds.length > 0) {
|
||||
await tx.judgeResult.deleteMany({
|
||||
where: { submission_id: { in: batchSubmissionIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.submission.deleteMany({
|
||||
where: { task_id: { in: idBatch } },
|
||||
});
|
||||
await tx.claim.deleteMany({
|
||||
where: { task_id: { in: idBatch } },
|
||||
});
|
||||
await tx.ledgerEntry.deleteMany({
|
||||
where: { task_id: { in: idBatch } },
|
||||
});
|
||||
await tx.auditEvent.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
entityType: "TASK",
|
||||
entityId: { in: idBatch },
|
||||
},
|
||||
{
|
||||
action: { startsWith: "SCOUT_DRAFT_" },
|
||||
entityId: { in: idBatch },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await tx.task.deleteMany({
|
||||
where: { id: { in: idBatch } },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Deleted ${taskCount} GitHub Issue task entries.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error("Purge script failed:", error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user