import { Router, type Request, type Response } from "express"; import { randomUUID } from "crypto"; import { db, jobs, invoices, type Job } from "@workspace/db"; import { eq, and } from "drizzle-orm"; import { CreateJobBody } from "@workspace/api-zod"; import { lnbitsService } from "../lib/lnbits.js"; import { agentService } from "../lib/agent.js"; import { pricingService } from "../lib/pricing.js"; const router = Router(); async function getJobById(id: string): Promise { const rows = await db.select().from(jobs).where(eq(jobs.id, id)).limit(1); return rows[0] ?? null; } async function getInvoiceById(id: string) { const rows = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1); return rows[0] ?? null; } /** * Checks whether the active invoice for a job has been paid and, if so, * advances the state machine. Returns the refreshed job after any transitions. */ async function advanceJob(job: Job): Promise { if (job.state === "awaiting_eval_payment" && job.evalInvoiceId) { const evalInvoice = await getInvoiceById(job.evalInvoiceId); if (!evalInvoice || evalInvoice.paid) return getJobById(job.id); const isPaid = await lnbitsService.checkInvoicePaid(evalInvoice.paymentHash); if (!isPaid) return job; const advanced = await db.transaction(async (tx) => { await tx .update(invoices) .set({ paid: true, paidAt: new Date() }) .where(eq(invoices.id, evalInvoice.id)); const updated = await tx .update(jobs) .set({ state: "evaluating", updatedAt: new Date() }) .where(and(eq(jobs.id, job.id), eq(jobs.state, "awaiting_eval_payment"))) .returning(); return updated.length > 0; }); if (!advanced) return getJobById(job.id); try { const evalResult = await agentService.evaluateRequest(job.request); if (evalResult.accepted) { const workFee = pricingService.calculateWorkFeeSats(job.request); const workInvoiceData = await lnbitsService.createInvoice( workFee, `Work fee for job ${job.id}`, ); const workInvoiceId = randomUUID(); await db.transaction(async (tx) => { await tx.insert(invoices).values({ id: workInvoiceId, jobId: job.id, paymentHash: workInvoiceData.paymentHash, paymentRequest: workInvoiceData.paymentRequest, amountSats: workFee, type: "work", paid: false, }); await tx .update(jobs) .set({ state: "awaiting_work_payment", workInvoiceId, workAmountSats: workFee, updatedAt: new Date(), }) .where(eq(jobs.id, job.id)); }); } else { await db .update(jobs) .set({ state: "rejected", rejectionReason: evalResult.reason, updatedAt: new Date() }) .where(eq(jobs.id, job.id)); } } catch (err) { const message = err instanceof Error ? err.message : "Evaluation error"; await db .update(jobs) .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) .where(eq(jobs.id, job.id)); } return getJobById(job.id); } if (job.state === "awaiting_work_payment" && job.workInvoiceId) { const workInvoice = await getInvoiceById(job.workInvoiceId); if (!workInvoice || workInvoice.paid) return getJobById(job.id); const isPaid = await lnbitsService.checkInvoicePaid(workInvoice.paymentHash); if (!isPaid) return job; const advanced = await db.transaction(async (tx) => { await tx .update(invoices) .set({ paid: true, paidAt: new Date() }) .where(eq(invoices.id, workInvoice.id)); const updated = await tx .update(jobs) .set({ state: "executing", updatedAt: new Date() }) .where(and(eq(jobs.id, job.id), eq(jobs.state, "awaiting_work_payment"))) .returning(); return updated.length > 0; }); if (!advanced) return getJobById(job.id); try { const workResult = await agentService.executeWork(job.request); await db .update(jobs) .set({ state: "complete", result: workResult.result, updatedAt: new Date() }) .where(eq(jobs.id, job.id)); } catch (err) { const message = err instanceof Error ? err.message : "Execution error"; await db .update(jobs) .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) .where(eq(jobs.id, job.id)); } return getJobById(job.id); } return job; } router.post("/jobs", async (req: Request, res: Response) => { const parseResult = CreateJobBody.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ error: "Invalid request: 'request' string is required" }); return; } const { request } = parseResult.data; try { const evalFee = pricingService.calculateEvalFeeSats(); const jobId = randomUUID(); const invoiceId = randomUUID(); const lnbitsInvoice = await lnbitsService.createInvoice( evalFee, `Eval fee for job ${jobId}`, ); await db.transaction(async (tx) => { await tx.insert(jobs).values({ id: jobId, request, state: "awaiting_eval_payment", evalAmountSats: evalFee, }); await tx.insert(invoices).values({ id: invoiceId, jobId, paymentHash: lnbitsInvoice.paymentHash, paymentRequest: lnbitsInvoice.paymentRequest, amountSats: evalFee, type: "eval", paid: false, }); await tx .update(jobs) .set({ evalInvoiceId: invoiceId, updatedAt: new Date() }) .where(eq(jobs.id, jobId)); }); res.status(201).json({ jobId, evalInvoice: { paymentRequest: lnbitsInvoice.paymentRequest, amountSats: evalFee, }, }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create job"; res.status(500).json({ error: message }); } }); router.get("/jobs/:id", async (req: Request, res: Response) => { const { id } = req.params as { id: string }; try { let job = await getJobById(id); if (!job) { res.status(404).json({ error: "Job not found" }); return; } const advanced = await advanceJob(job); if (advanced) job = advanced; const base = { jobId: job.id, state: job.state }; switch (job.state) { case "awaiting_eval_payment": { const inv = job.evalInvoiceId ? await getInvoiceById(job.evalInvoiceId) : null; res.json({ ...base, ...(inv ? { evalInvoice: { paymentRequest: inv.paymentRequest, amountSats: inv.amountSats } } : {}), }); break; } case "awaiting_work_payment": { const inv = job.workInvoiceId ? await getInvoiceById(job.workInvoiceId) : null; res.json({ ...base, ...(inv ? { workInvoice: { paymentRequest: inv.paymentRequest, amountSats: inv.amountSats } } : {}), }); break; } case "rejected": res.json({ ...base, reason: job.rejectionReason ?? undefined }); break; case "complete": res.json({ ...base, result: job.result ?? undefined }); break; case "failed": res.json({ ...base, errorMessage: job.errorMessage ?? undefined }); break; default: res.json(base); } } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch job"; res.status(500).json({ error: message }); } }); export default router;