Task #3: MVP API — payment-gated jobs + demo endpoint
OpenAPI spec (lib/api-spec/openapi.yaml)
- Added POST /jobs, GET /jobs/{id}, GET /demo endpoints
- Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse,
InvoiceInfo, JobState, DemoResponse, ErrorResponse
- Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc.
Jobs router (artifacts/api-server/src/routes/jobs.ts)
- POST /jobs: validates body, creates LNbits eval invoice, inserts job +
invoice in a DB transaction, returns { jobId, evalInvoice }
- GET /jobs/:id: fetches job, calls advanceJob() helper, returns state-
appropriate payload (eval/work invoice, reason, result, errorMessage)
- advanceJob() state machine:
- awaiting_eval_payment: checks LNbits, atomically marks paid + advances
state via optimistic WHERE state='awaiting_eval_payment'; runs
AgentService.evaluateRequest, branches to awaiting_work_payment or rejected
- awaiting_work_payment: same pattern for work invoice, runs
AgentService.executeWork, advances to complete
- Any agent/LNbits error transitions job to failed
Demo router (artifacts/api-server/src/routes/demo.ts)
- GET /demo?request=...: in-memory rate limiter (5 req/hour per IP)
- Explicit guard for missing request param (coerce.string() workaround)
- Calls AgentService.executeWork directly, returns { result }
Dev router (artifacts/api-server/src/routes/dev.ts)
- POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory
- Only mounted when NODE_ENV !== 'production'
Route index updated to mount all three routers
replit.md: documented full curl flow with all 6 steps, demo endpoint,
and dev stub-pay trigger
End-to-end verified with curl:
- Full flow: create → eval pay → evaluating → work pay → executing → complete
- Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
|
|
|
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";
|
2026-03-18 15:34:05 +00:00
|
|
|
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
|
Task #3: MVP API — payment-gated jobs + demo endpoint
OpenAPI spec (lib/api-spec/openapi.yaml)
- Added POST /jobs, GET /jobs/{id}, GET /demo endpoints
- Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse,
InvoiceInfo, JobState, DemoResponse, ErrorResponse
- Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc.
Jobs router (artifacts/api-server/src/routes/jobs.ts)
- POST /jobs: validates body, creates LNbits eval invoice, inserts job +
invoice in a DB transaction, returns { jobId, evalInvoice }
- GET /jobs/:id: fetches job, calls advanceJob() helper, returns state-
appropriate payload (eval/work invoice, reason, result, errorMessage)
- advanceJob() state machine:
- awaiting_eval_payment: checks LNbits, atomically marks paid + advances
state via optimistic WHERE state='awaiting_eval_payment'; runs
AgentService.evaluateRequest, branches to awaiting_work_payment or rejected
- awaiting_work_payment: same pattern for work invoice, runs
AgentService.executeWork, advances to complete
- Any agent/LNbits error transitions job to failed
Demo router (artifacts/api-server/src/routes/demo.ts)
- GET /demo?request=...: in-memory rate limiter (5 req/hour per IP)
- Explicit guard for missing request param (coerce.string() workaround)
- Calls AgentService.executeWork directly, returns { result }
Dev router (artifacts/api-server/src/routes/dev.ts)
- POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory
- Only mounted when NODE_ENV !== 'production'
Route index updated to mount all three routers
replit.md: documented full curl flow with all 6 steps, demo endpoint,
and dev stub-pay trigger
End-to-end verified with curl:
- Full flow: create → eval pay → evaluating → work pay → executing → complete
- Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
|
|
|
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<Job | null> {
|
|
|
|
|
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<Job | null> {
|
|
|
|
|
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) => {
|
2026-03-18 15:34:05 +00:00
|
|
|
const paramResult = GetJobParams.safeParse(req.params);
|
|
|
|
|
if (!paramResult.success) {
|
|
|
|
|
res.status(400).json({ error: "Invalid job id" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const { id } = paramResult.data;
|
Task #3: MVP API — payment-gated jobs + demo endpoint
OpenAPI spec (lib/api-spec/openapi.yaml)
- Added POST /jobs, GET /jobs/{id}, GET /demo endpoints
- Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse,
InvoiceInfo, JobState, DemoResponse, ErrorResponse
- Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc.
Jobs router (artifacts/api-server/src/routes/jobs.ts)
- POST /jobs: validates body, creates LNbits eval invoice, inserts job +
invoice in a DB transaction, returns { jobId, evalInvoice }
- GET /jobs/:id: fetches job, calls advanceJob() helper, returns state-
appropriate payload (eval/work invoice, reason, result, errorMessage)
- advanceJob() state machine:
- awaiting_eval_payment: checks LNbits, atomically marks paid + advances
state via optimistic WHERE state='awaiting_eval_payment'; runs
AgentService.evaluateRequest, branches to awaiting_work_payment or rejected
- awaiting_work_payment: same pattern for work invoice, runs
AgentService.executeWork, advances to complete
- Any agent/LNbits error transitions job to failed
Demo router (artifacts/api-server/src/routes/demo.ts)
- GET /demo?request=...: in-memory rate limiter (5 req/hour per IP)
- Explicit guard for missing request param (coerce.string() workaround)
- Calls AgentService.executeWork directly, returns { result }
Dev router (artifacts/api-server/src/routes/dev.ts)
- POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory
- Only mounted when NODE_ENV !== 'production'
Route index updated to mount all three routers
replit.md: documented full curl flow with all 6 steps, demo endpoint,
and dev stub-pay trigger
End-to-end verified with curl:
- Full flow: create → eval pay → evaluating → work pay → executing → complete
- Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
|
|
|
|
|
|
|
|
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;
|