chore: initial commit with Phase 0 setup

This commit is contained in:
OG T
2026-06-06 22:55:45 +08:00
commit 9e79e58f87
56 changed files with 16088 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { logAuditEvent } from "@/lib/audit";
import { TaskStatus } from "@agent-bounty/contracts";
// Optional: restrict to cron secret
// export const maxDuration = 60; // Next.js edge/serverless config
export async function GET(req: Request) {
// Find all claims that have expired, but the task is still EXECUTING
const now = new Date();
const expiredClaims = await prisma.claim.findMany({
where: {
status: TaskStatus.EXECUTING,
expires_at: { lt: now },
},
include: {
task: true,
}
});
const rolledBackIds: string[] = [];
for (const claim of expiredClaims) {
if (claim.task.status === TaskStatus.EXECUTING) {
await prisma.$transaction(async (tx) => {
// Rollback Task
await tx.task.update({
where: { id: claim.task_id },
data: {
status: TaskStatus.OPEN,
error_classification: "claim_timeout",
}
});
// Rollback Claim
await tx.claim.update({
where: { id: claim.id },
data: { status: "CANCELLED" }
});
await logAuditEvent(tx, {
actorType: "SYSTEM",
action: "CLAIM_TIMEOUT_REAPER",
entityType: "TASK",
entityId: claim.task_id,
beforeState: { status: TaskStatus.EXECUTING },
afterState: { status: TaskStatus.OPEN },
reason: `Claim ${claim.id} expired at ${claim.expires_at.toISOString()}`,
});
});
rolledBackIds.push(claim.task_id);
}
}
return NextResponse.json({
swept: rolledBackIds.length,
task_ids: rolledBackIds,
});
}

View File

@@ -0,0 +1,245 @@
import { NextRequest, NextResponse } from "next/server";
import {
ListOpenTasksRequestSchema,
ClaimTaskRequestSchema,
SubmitSolutionRequestSchema,
TaskStatus,
JudgeOverallResult
} from "@agent-bounty/contracts";
import { prisma } from "@/lib/prisma";
import { runSubmissionInSandbox } from "@/lib/sandbox";
import { logAuditEvent } from "@/lib/audit";
import { redis } from "@/lib/redis";
import crypto from "crypto";
export async function POST(request: NextRequest, props: { params: Promise<{ tool: string }> }) {
const params = await props.params;
const tool = params.tool;
const authHeader = request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
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) {
return NextResponse.json({ error: "Forbidden: Invalid API Key" }, { status: 403 });
}
try {
const body = await request.json();
switch (tool) {
case "list_open_tasks": {
ListOpenTasksRequestSchema.parse(body);
const tasks = await prisma.task.findMany({
where: { status: TaskStatus.OPEN },
});
const formattedTasks = tasks.map((t) => ({
task_id: t.id,
title: t.title,
status: t.status,
difficulty: t.difficulty,
reward: {
amount: t.reward_amount,
currency: t.reward_currency,
display_amount: `$${(t.reward_amount / 100).toFixed(2)}`
},
required_stack: t.required_stack,
scope_clarity_score: t.scope_clarity_score,
created_at: t.created_at.toISOString(),
description_preview: t.description.substring(0, 100) + (t.description.length > 100 ? "..." : ""),
}));
return NextResponse.json({
tasks: formattedTasks,
total_open: formattedTasks.length,
stockout_warning: formattedTasks.length === 0,
});
}
case "claim_task": {
const parsed = ClaimTaskRequestSchema.parse(body);
const claim = await prisma.$transaction(async (tx) => {
const updated = await tx.task.updateMany({
where: { id: parsed.task_id, status: TaskStatus.OPEN },
data: { status: TaskStatus.EXECUTING }
});
if (updated.count === 0) {
throw new Error("Task is not OPEN or does not exist");
}
const task = await tx.task.findUniqueOrThrow({ where: { id: parsed.task_id } });
const newClaim = await tx.claim.create({
data: {
task_id: task.id,
developer_wallet: parsed.developer_wallet,
status: TaskStatus.EXECUTING,
claim_token: crypto.randomUUID(),
held_amount: task.reward_amount,
held_currency: task.reward_currency,
expires_at: new Date(Date.now() + 3600000)
}
});
await logAuditEvent(tx, {
actorType: "AGENT",
actorId: parsed.developer_wallet,
action: "CLAIM_TASK",
entityType: "TASK",
entityId: task.id,
beforeState: { status: TaskStatus.OPEN },
afterState: { status: TaskStatus.EXECUTING }
});
return newClaim;
});
// Set Redis TTL key (3600 seconds)
await redis.set(`vw:task:${claim.task_id}:executing`, claim.claim_token, "EX", 3600);
return NextResponse.json({
task_id: claim.task_id,
status: claim.status,
held_amount: claim.held_amount,
held_currency: claim.held_currency,
expires_at: claim.expires_at.toISOString(),
claim_token: claim.claim_token,
});
}
case "submit_solution": {
const parsed = SubmitSolutionRequestSchema.parse(body);
const submission = await prisma.$transaction(async (tx) => {
const claim = await tx.claim.findUnique({ where: { claim_token: parsed.claim_token } });
if (!claim || claim.task_id !== parsed.task_id || claim.status !== TaskStatus.EXECUTING) {
throw new Error("Invalid claim token or claim is not EXECUTING");
}
const updatedTask = await tx.task.updateMany({
where: { id: parsed.task_id, status: TaskStatus.EXECUTING },
data: { status: TaskStatus.VERIFYING }
});
if (updatedTask.count === 0) {
throw new Error("Task is not EXECUTING");
}
await tx.claim.update({
where: { id: claim.id },
data: { status: TaskStatus.VERIFYING }
});
const newSubmission = await tx.submission.create({
data: {
task_id: parsed.task_id,
claim_id: claim.id,
status: TaskStatus.VERIFYING,
deliverables: parsed.deliverables as any,
estimated_judge_complete_at: new Date(Date.now() + 300000)
}
});
await logAuditEvent(tx, {
actorType: "AGENT",
action: "SUBMIT_SOLUTION",
entityType: "TASK",
entityId: parsed.task_id,
beforeState: { status: TaskStatus.EXECUTING },
afterState: { status: TaskStatus.VERIFYING }
});
return newSubmission;
});
// Async trigger E2B Sandbox evaluation
const taskObj = await prisma.task.findUnique({ where: { id: submission.task_id }});
if (taskObj && typeof taskObj.acceptance_criteria === "object" && taskObj.acceptance_criteria !== null) {
const criteria = taskObj.acceptance_criteria as any;
if (criteria.test_file_content) {
// Fire and forget
runSubmissionInSandbox(
submission.id,
parsed.deliverables as Record<string, string>,
criteria.test_file_content
).then(async (result) => {
// Update submission
await prisma.submission.update({
where: { id: submission.id },
data: { status: "JUDGED" }
});
// Create JudgeResult
await prisma.judgeResult.create({
data: {
submission_id: submission.id,
overall_result: result.overall_result,
tests: result.tests,
artifacts: result.artifacts,
error_classification: result.error_classification,
resource_usage: result.resource_usage
}
});
// Update Task & Claim Status
const newTaskStatus = result.overall_result === JudgeOverallResult.PASS
? TaskStatus.COMPLETED
: TaskStatus.FAILED_RETRYABLE;
await prisma.task.update({
where: { id: submission.task_id },
data: { status: newTaskStatus }
});
await prisma.claim.update({
where: { id: submission.claim_id },
data: { status: newTaskStatus }
});
// @ts-ignore prisma transaction client vs prisma client
await logAuditEvent(prisma, {
actorType: "SYSTEM",
action: "JUDGE_COMPLETE",
entityType: "TASK",
entityId: submission.task_id,
beforeState: { status: TaskStatus.VERIFYING },
afterState: { status: newTaskStatus },
metadata: { overall_result: result.overall_result, error_classification: result.error_classification }
});
}).catch(console.error);
}
}
return NextResponse.json({
task_id: submission.task_id,
submission_id: submission.id,
status: submission.status,
estimated_judge_complete_at: submission.estimated_judge_complete_at?.toISOString() ?? new Date().toISOString(),
});
}
case "check_payout_status": {
// Mocked for now until Settlement phase is implemented
return NextResponse.json({
task_id: body.task_id,
phase: "PAYOUT_READY",
amount: 100,
currency: "USD",
updated_at: new Date().toISOString(),
});
}
default:
return NextResponse.json({ error: `Unknown tool: ${tool}` }, { status: 404 });
}
} catch (error: any) {
console.error(`[API Gateway] Error handling ${tool}:`, error);
return NextResponse.json({ error: error.message || String(error) }, { status: 400 });
}
}