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/🆔 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
This commit is contained in:
alexpaynex
2026-03-18 15:31:26 +00:00
parent 9ec5e20a10
commit 4e8adbcb93
19 changed files with 1050 additions and 6 deletions

View File

@@ -0,0 +1,65 @@
import { Router, type Request, type Response } from "express";
import { RunDemoQueryParams } from "@workspace/api-zod";
import { agentService } from "../lib/agent.js";
const router = Router();
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
const ipHits = new Map<string, { count: number; resetAt: number }>();
function checkRateLimit(ip: string): { allowed: boolean; resetAt: number } {
const now = Date.now();
const entry = ipHits.get(ip);
if (!entry || now >= entry.resetAt) {
ipHits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, resetAt: now + RATE_LIMIT_WINDOW_MS };
}
if (entry.count >= RATE_LIMIT_MAX) {
return { allowed: false, resetAt: entry.resetAt };
}
entry.count += 1;
return { allowed: true, resetAt: entry.resetAt };
}
router.get("/demo", async (req: Request, res: Response) => {
const ip =
(req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
"unknown";
const { allowed, resetAt } = checkRateLimit(ip);
if (!allowed) {
const secsUntilReset = Math.ceil((resetAt - Date.now()) / 1000);
res.status(429).json({
error: `Rate limit exceeded. Try again in ${secsUntilReset}s (5 requests per hour per IP).`,
});
return;
}
if (!req.query.request) {
res.status(400).json({ error: "Missing required query param: request" });
return;
}
const parseResult = RunDemoQueryParams.safeParse(req.query);
if (!parseResult.success) {
res.status(400).json({ error: "Invalid query param: request must be a non-empty string" });
return;
}
const { request } = parseResult.data;
try {
const { result } = await agentService.executeWork(request);
res.json({ result });
} catch (err) {
const message = err instanceof Error ? err.message : "Agent error";
res.status(500).json({ error: message });
}
});
export default router;