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:
5
.replit
5
.replit
@@ -1,4 +1,4 @@
|
||||
modules = ["nodejs-24"]
|
||||
modules = ["nodejs-24", "postgresql-16"]
|
||||
|
||||
[[artifacts]]
|
||||
id = "artifacts/api-server"
|
||||
@@ -24,3 +24,6 @@ expertMode = true
|
||||
[postMerge]
|
||||
path = "scripts/post-merge.sh"
|
||||
timeoutMs = 20000
|
||||
|
||||
[nix]
|
||||
channel = "stable-25_05"
|
||||
|
||||
65
artifacts/api-server/src/routes/demo.ts
Normal file
65
artifacts/api-server/src/routes/demo.ts
Normal 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;
|
||||
24
artifacts/api-server/src/routes/dev.ts
Normal file
24
artifacts/api-server/src/routes/dev.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Development-only routes. Not mounted in production.
|
||||
*/
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { lnbitsService } from "../lib/lnbits.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /dev/stub/pay/:paymentHash
|
||||
* Marks a stub invoice as paid in the in-memory store.
|
||||
* Only available when LNbitsService is in stub mode (i.e. no real LNbits creds).
|
||||
*/
|
||||
router.post("/dev/stub/pay/:paymentHash", (req: Request, res: Response) => {
|
||||
const { paymentHash } = req.params as { paymentHash: string };
|
||||
if (!lnbitsService.stubMode) {
|
||||
res.status(400).json({ error: "Stub mode is not active. Real LNbits credentials are configured." });
|
||||
return;
|
||||
}
|
||||
lnbitsService.stubMarkPaid(paymentHash);
|
||||
res.json({ ok: true, paymentHash });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import healthRouter from "./health";
|
||||
import healthRouter from "./health.js";
|
||||
import jobsRouter from "./jobs.js";
|
||||
import demoRouter from "./demo.js";
|
||||
import devRouter from "./dev.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(healthRouter);
|
||||
router.use(jobsRouter);
|
||||
router.use(demoRouter);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
router.use(devRouter);
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
242
artifacts/api-server/src/routes/jobs.ts
Normal file
242
artifacts/api-server/src/routes/jobs.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
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<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) => {
|
||||
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;
|
||||
@@ -8,3 +8,52 @@
|
||||
export interface HealthStatus {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface InvoiceInfo {
|
||||
paymentRequest: string;
|
||||
amountSats: number;
|
||||
}
|
||||
|
||||
export interface CreateJobRequest {
|
||||
/** @minLength 1 */
|
||||
request: string;
|
||||
}
|
||||
|
||||
export interface CreateJobResponse {
|
||||
jobId: string;
|
||||
evalInvoice: InvoiceInfo;
|
||||
}
|
||||
|
||||
export type JobState = (typeof JobState)[keyof typeof JobState];
|
||||
|
||||
export const JobState = {
|
||||
awaiting_eval_payment: "awaiting_eval_payment",
|
||||
evaluating: "evaluating",
|
||||
rejected: "rejected",
|
||||
awaiting_work_payment: "awaiting_work_payment",
|
||||
executing: "executing",
|
||||
complete: "complete",
|
||||
failed: "failed",
|
||||
} as const;
|
||||
|
||||
export interface JobStatusResponse {
|
||||
jobId: string;
|
||||
state: JobState;
|
||||
evalInvoice?: InvoiceInfo;
|
||||
workInvoice?: InvoiceInfo;
|
||||
reason?: string;
|
||||
result?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface DemoResponse {
|
||||
result: string;
|
||||
}
|
||||
|
||||
export type RunDemoParams = {
|
||||
request: string;
|
||||
};
|
||||
|
||||
@@ -5,18 +5,29 @@
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
MutationFunction,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type { HealthStatus } from "./api.schemas";
|
||||
import type {
|
||||
CreateJobRequest,
|
||||
CreateJobResponse,
|
||||
DemoResponse,
|
||||
ErrorResponse,
|
||||
HealthStatus,
|
||||
JobStatusResponse,
|
||||
RunDemoParams,
|
||||
} from "./api.schemas";
|
||||
|
||||
import { customFetch } from "../custom-fetch";
|
||||
import type { ErrorType } from "../custom-fetch";
|
||||
import type { ErrorType, BodyType } from "../custom-fetch";
|
||||
|
||||
type AwaitedInput<T> = PromiseLike<T> | T;
|
||||
|
||||
@@ -99,3 +110,253 @@ export function useHealthCheck<
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a request, creates a job row, and issues an eval fee Lightning invoice.
|
||||
* @summary Create a new agent job
|
||||
*/
|
||||
export const getCreateJobUrl = () => {
|
||||
return `/api/jobs`;
|
||||
};
|
||||
|
||||
export const createJob = async (
|
||||
createJobRequest: CreateJobRequest,
|
||||
options?: RequestInit,
|
||||
): Promise<CreateJobResponse> => {
|
||||
return customFetch<CreateJobResponse>(getCreateJobUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(createJobRequest),
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateJobMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createJob>>,
|
||||
TError,
|
||||
{ data: BodyType<CreateJobRequest> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createJob>>,
|
||||
TError,
|
||||
{ data: BodyType<CreateJobRequest> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["createJob"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createJob>>,
|
||||
{ data: BodyType<CreateJobRequest> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createJob(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateJobMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createJob>>
|
||||
>;
|
||||
export type CreateJobMutationBody = BodyType<CreateJobRequest>;
|
||||
export type CreateJobMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Create a new agent job
|
||||
*/
|
||||
export const useCreateJob = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createJob>>,
|
||||
TError,
|
||||
{ data: BodyType<CreateJobRequest> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createJob>>,
|
||||
TError,
|
||||
{ data: BodyType<CreateJobRequest> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateJobMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns current job state. Automatically advances the state machine when a pending invoice is found to be paid.
|
||||
* @summary Get job status
|
||||
*/
|
||||
export const getGetJobUrl = (id: string) => {
|
||||
return `/api/jobs/${id}`;
|
||||
};
|
||||
|
||||
export const getJob = async (
|
||||
id: string,
|
||||
options?: RequestInit,
|
||||
): Promise<JobStatusResponse> => {
|
||||
return customFetch<JobStatusResponse>(getGetJobUrl(id), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetJobQueryKey = (id: string) => {
|
||||
return [`/api/jobs/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetJobQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getJob>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: string,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(id);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getJob>>> = ({
|
||||
signal,
|
||||
}) => getJob(id, { signal, ...requestOptions });
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetJobQueryResult = NonNullable<Awaited<ReturnType<typeof getJob>>>;
|
||||
export type GetJobQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Get job status
|
||||
*/
|
||||
|
||||
export function useGetJob<
|
||||
TData = Awaited<ReturnType<typeof getJob>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: string,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetJobQueryOptions(id, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the agent without payment. Limited to 5 requests per IP per hour.
|
||||
* @summary Free demo (rate-limited)
|
||||
*/
|
||||
export const getRunDemoUrl = (params: RunDemoParams) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/demo?${stringifiedParams}`
|
||||
: `/api/demo`;
|
||||
};
|
||||
|
||||
export const runDemo = async (
|
||||
params: RunDemoParams,
|
||||
options?: RequestInit,
|
||||
): Promise<DemoResponse> => {
|
||||
return customFetch<DemoResponse>(getRunDemoUrl(params), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getRunDemoQueryKey = (params?: RunDemoParams) => {
|
||||
return [`/api/demo`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getRunDemoQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof runDemo>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
params: RunDemoParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof runDemo>>, TError, TData>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getRunDemoQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof runDemo>>> = ({
|
||||
signal,
|
||||
}) => runDemo(params, { signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof runDemo>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type RunDemoQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof runDemo>>
|
||||
>;
|
||||
export type RunDemoQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Free demo (rate-limited)
|
||||
*/
|
||||
|
||||
export function useRunDemo<
|
||||
TData = Awaited<ReturnType<typeof runDemo>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
params: RunDemoParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof runDemo>>, TError, TData>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getRunDemoQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ servers:
|
||||
tags:
|
||||
- name: health
|
||||
description: Health operations
|
||||
- name: jobs
|
||||
description: Payment-gated agent job operations
|
||||
- name: demo
|
||||
description: Free demo endpoint (rate-limited)
|
||||
paths:
|
||||
/healthz:
|
||||
get:
|
||||
@@ -24,6 +28,105 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HealthStatus"
|
||||
/jobs:
|
||||
post:
|
||||
operationId: createJob
|
||||
tags: [jobs]
|
||||
summary: Create a new agent job
|
||||
description: Accepts a request, creates a job row, and issues an eval fee Lightning invoice.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateJobRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Job created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateJobResponse"
|
||||
"400":
|
||||
description: Invalid request body
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/jobs/{id}:
|
||||
get:
|
||||
operationId: getJob
|
||||
tags: [jobs]
|
||||
summary: Get job status
|
||||
description: Returns current job state. Automatically advances the state machine when a pending invoice is found to be paid.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/JobStatusResponse"
|
||||
"404":
|
||||
description: Job not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
/demo:
|
||||
get:
|
||||
operationId: runDemo
|
||||
tags: [demo]
|
||||
summary: Free demo (rate-limited)
|
||||
description: Runs the agent without payment. Limited to 5 requests per IP per hour.
|
||||
parameters:
|
||||
- name: request
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Demo result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DemoResponse"
|
||||
"400":
|
||||
description: Missing or invalid request param
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"429":
|
||||
description: Rate limit exceeded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
components:
|
||||
schemas:
|
||||
HealthStatus:
|
||||
@@ -33,4 +136,75 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required:
|
||||
- error
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
InvoiceInfo:
|
||||
type: object
|
||||
required:
|
||||
- paymentRequest
|
||||
- amountSats
|
||||
properties:
|
||||
paymentRequest:
|
||||
type: string
|
||||
amountSats:
|
||||
type: integer
|
||||
CreateJobRequest:
|
||||
type: object
|
||||
required:
|
||||
- request
|
||||
properties:
|
||||
request:
|
||||
type: string
|
||||
minLength: 1
|
||||
CreateJobResponse:
|
||||
type: object
|
||||
required:
|
||||
- jobId
|
||||
- evalInvoice
|
||||
properties:
|
||||
jobId:
|
||||
type: string
|
||||
evalInvoice:
|
||||
$ref: "#/components/schemas/InvoiceInfo"
|
||||
JobState:
|
||||
type: string
|
||||
enum:
|
||||
- awaiting_eval_payment
|
||||
- evaluating
|
||||
- rejected
|
||||
- awaiting_work_payment
|
||||
- executing
|
||||
- complete
|
||||
- failed
|
||||
JobStatusResponse:
|
||||
type: object
|
||||
required:
|
||||
- jobId
|
||||
- state
|
||||
properties:
|
||||
jobId:
|
||||
type: string
|
||||
state:
|
||||
$ref: "#/components/schemas/JobState"
|
||||
evalInvoice:
|
||||
$ref: "#/components/schemas/InvoiceInfo"
|
||||
workInvoice:
|
||||
$ref: "#/components/schemas/InvoiceInfo"
|
||||
reason:
|
||||
type: string
|
||||
result:
|
||||
type: string
|
||||
errorMessage:
|
||||
type: string
|
||||
DemoResponse:
|
||||
type: object
|
||||
required:
|
||||
- result
|
||||
properties:
|
||||
result:
|
||||
type: string
|
||||
|
||||
@@ -14,3 +14,60 @@ import * as zod from "zod";
|
||||
export const HealthCheckResponse = zod.object({
|
||||
status: zod.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Accepts a request, creates a job row, and issues an eval fee Lightning invoice.
|
||||
* @summary Create a new agent job
|
||||
*/
|
||||
|
||||
export const CreateJobBody = zod.object({
|
||||
request: zod.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns current job state. Automatically advances the state machine when a pending invoice is found to be paid.
|
||||
* @summary Get job status
|
||||
*/
|
||||
export const GetJobParams = zod.object({
|
||||
id: zod.coerce.string(),
|
||||
});
|
||||
|
||||
export const GetJobResponse = zod.object({
|
||||
jobId: zod.string(),
|
||||
state: zod.enum([
|
||||
"awaiting_eval_payment",
|
||||
"evaluating",
|
||||
"rejected",
|
||||
"awaiting_work_payment",
|
||||
"executing",
|
||||
"complete",
|
||||
"failed",
|
||||
]),
|
||||
evalInvoice: zod
|
||||
.object({
|
||||
paymentRequest: zod.string(),
|
||||
amountSats: zod.number(),
|
||||
})
|
||||
.optional(),
|
||||
workInvoice: zod
|
||||
.object({
|
||||
paymentRequest: zod.string(),
|
||||
amountSats: zod.number(),
|
||||
})
|
||||
.optional(),
|
||||
reason: zod.string().optional(),
|
||||
result: zod.string().optional(),
|
||||
errorMessage: zod.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs the agent without payment. Limited to 5 requests per IP per hour.
|
||||
* @summary Free demo (rate-limited)
|
||||
*/
|
||||
export const RunDemoQueryParams = zod.object({
|
||||
request: zod.coerce.string(),
|
||||
});
|
||||
|
||||
export const RunDemoResponse = zod.object({
|
||||
result: zod.string(),
|
||||
});
|
||||
|
||||
12
lib/api-zod/src/generated/types/createJobRequest.ts
Normal file
12
lib/api-zod/src/generated/types/createJobRequest.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface CreateJobRequest {
|
||||
/** @minLength 1 */
|
||||
request: string;
|
||||
}
|
||||
13
lib/api-zod/src/generated/types/createJobResponse.ts
Normal file
13
lib/api-zod/src/generated/types/createJobResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { InvoiceInfo } from "./invoiceInfo";
|
||||
|
||||
export interface CreateJobResponse {
|
||||
jobId: string;
|
||||
evalInvoice: InvoiceInfo;
|
||||
}
|
||||
11
lib/api-zod/src/generated/types/demoResponse.ts
Normal file
11
lib/api-zod/src/generated/types/demoResponse.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface DemoResponse {
|
||||
result: string;
|
||||
}
|
||||
11
lib/api-zod/src/generated/types/errorResponse.ts
Normal file
11
lib/api-zod/src/generated/types/errorResponse.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
@@ -6,4 +6,12 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export * from "./createJobRequest";
|
||||
export * from "./createJobResponse";
|
||||
export * from "./demoResponse";
|
||||
export * from "./errorResponse";
|
||||
export * from "./healthStatus";
|
||||
export * from "./invoiceInfo";
|
||||
export * from "./jobState";
|
||||
export * from "./jobStatusResponse";
|
||||
export * from "./runDemoParams";
|
||||
|
||||
12
lib/api-zod/src/generated/types/invoiceInfo.ts
Normal file
12
lib/api-zod/src/generated/types/invoiceInfo.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface InvoiceInfo {
|
||||
paymentRequest: string;
|
||||
amountSats: number;
|
||||
}
|
||||
19
lib/api-zod/src/generated/types/jobState.ts
Normal file
19
lib/api-zod/src/generated/types/jobState.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type JobState = (typeof JobState)[keyof typeof JobState];
|
||||
|
||||
export const JobState = {
|
||||
awaiting_eval_payment: "awaiting_eval_payment",
|
||||
evaluating: "evaluating",
|
||||
rejected: "rejected",
|
||||
awaiting_work_payment: "awaiting_work_payment",
|
||||
executing: "executing",
|
||||
complete: "complete",
|
||||
failed: "failed",
|
||||
} as const;
|
||||
19
lib/api-zod/src/generated/types/jobStatusResponse.ts
Normal file
19
lib/api-zod/src/generated/types/jobStatusResponse.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { InvoiceInfo } from "./invoiceInfo";
|
||||
import type { JobState } from "./jobState";
|
||||
|
||||
export interface JobStatusResponse {
|
||||
jobId: string;
|
||||
state: JobState;
|
||||
evalInvoice?: InvoiceInfo;
|
||||
workInvoice?: InvoiceInfo;
|
||||
reason?: string;
|
||||
result?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
11
lib/api-zod/src/generated/types/runDemoParams.ts
Normal file
11
lib/api-zod/src/generated/types/runDemoParams.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type RunDemoParams = {
|
||||
request: string;
|
||||
};
|
||||
44
replit.md
44
replit.md
@@ -111,6 +111,50 @@ Generated Zod schemas from the OpenAPI spec (e.g. `HealthCheckResponse`). Used b
|
||||
|
||||
Generated React Query hooks and fetch client from the OpenAPI spec (e.g. `useHealthCheck`, `healthCheck`).
|
||||
|
||||
### `artifacts/api-server` — Timmy API endpoints
|
||||
|
||||
#### Payment-gated job flow
|
||||
|
||||
```bash
|
||||
BASE="https://${REPLIT_DEV_DOMAIN}" # or http://localhost:8080 in dev
|
||||
|
||||
# 1. Create a job (returns eval invoice)
|
||||
curl -s -X POST "$BASE/api/jobs" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"request": "Write a haiku about lightning payments"}'
|
||||
# → {"jobId":"…","evalInvoice":{"paymentRequest":"lnbcrt10u1…","amountSats":10}}
|
||||
|
||||
# 2. Poll status (returns eval invoice while unpaid)
|
||||
curl -s "$BASE/api/jobs/<jobId>"
|
||||
|
||||
# 3. (Stub mode only) Mark eval invoice paid
|
||||
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
|
||||
|
||||
# 4. Poll again — auto-advances to awaiting_work_payment, returns work invoice
|
||||
curl -s "$BASE/api/jobs/<jobId>"
|
||||
|
||||
# 5. (Stub mode only) Mark work invoice paid
|
||||
curl -s -X POST "$BASE/api/dev/stub/pay/<workPaymentHash>"
|
||||
|
||||
# 6. Poll again — auto-advances to complete, returns result
|
||||
curl -s "$BASE/api/jobs/<jobId>"
|
||||
# → {"jobId":"…","state":"complete","result":"…"}
|
||||
```
|
||||
|
||||
Job states: `awaiting_eval_payment` → `evaluating` → `awaiting_work_payment` → `executing` → `complete` | `rejected` | `failed`
|
||||
|
||||
#### Free demo endpoint (rate-limited: 5 req/hour per IP)
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/demo?request=Explain+proof+of+work+in+one+sentence"
|
||||
# → {"result":"…"}
|
||||
```
|
||||
|
||||
#### Dev-only stub payment trigger
|
||||
|
||||
`POST /api/dev/stub/pay/:paymentHash` — marks a stub invoice paid in-memory.
|
||||
Only available in development (`NODE_ENV !== 'production'`).
|
||||
|
||||
### `scripts` (`@workspace/scripts`)
|
||||
|
||||
Utility scripts package. Each script is a `.ts` file in `src/` with a corresponding npm script in `package.json`. Run scripts via `pnpm --filter @workspace/scripts run <script>`. Scripts can import any workspace package (e.g., `@workspace/db`) by adding it as a dependency in `scripts/package.json`.
|
||||
|
||||
Reference in New Issue
Block a user