chore: initial commit with Phase 0 setup
This commit is contained in:
61
apps/web/src/app/api/cron/reaper/route.ts
Normal file
61
apps/web/src/app/api/cron/reaper/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
245
apps/web/src/app/api/mcp/[tool]/route.ts
Normal file
245
apps/web/src/app/api/mcp/[tool]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user