diff --git a/.replit b/.replit index 8dc8fdd..f904759 100644 --- a/.replit +++ b/.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" diff --git a/artifacts/api-server/src/routes/demo.ts b/artifacts/api-server/src/routes/demo.ts new file mode 100644 index 0000000..5511343 --- /dev/null +++ b/artifacts/api-server/src/routes/demo.ts @@ -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(); + +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; diff --git a/artifacts/api-server/src/routes/dev.ts b/artifacts/api-server/src/routes/dev.ts new file mode 100644 index 0000000..fa567d8 --- /dev/null +++ b/artifacts/api-server/src/routes/dev.ts @@ -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; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77a..fbe097f 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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; diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts new file mode 100644 index 0000000..e4b33f5 --- /dev/null +++ b/artifacts/api-server/src/routes/jobs.ts @@ -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 { + 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; diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 0439d7d..e21e212 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; +}; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index ba51403..63374e3 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 = PromiseLike | 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 => { + return customFetch(getCreateJobUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createJobRequest), + }); +}; + +export const getCreateJobMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + 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>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return createJob(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateJobMutationResult = NonNullable< + Awaited> +>; +export type CreateJobMutationBody = BodyType; +export type CreateJobMutationError = ErrorType; + +/** + * @summary Create a new agent job + */ +export const useCreateJob = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + 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 => { + return customFetch(getGetJobUrl(id), { + ...options, + method: "GET", + }); +}; + +export const getGetJobQueryKey = (id: string) => { + return [`/api/jobs/${id}`] as const; +}; + +export const getGetJobQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: UseQueryOptions>, TError, TData>; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(id); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getJob(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: QueryKey; + }; +}; + +export type GetJobQueryResult = NonNullable>>; +export type GetJobQueryError = ErrorType; + +/** + * @summary Get job status + */ + +export function useGetJob< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: UseQueryOptions>, TError, TData>; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetJobQueryOptions(id, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + 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 => { + return customFetch(getRunDemoUrl(params), { + ...options, + method: "GET", + }); +}; + +export const getRunDemoQueryKey = (params?: RunDemoParams) => { + return [`/api/demo`, ...(params ? [params] : [])] as const; +}; + +export const getRunDemoQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + params: RunDemoParams, + options?: { + query?: UseQueryOptions>, TError, TData>; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getRunDemoQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => runDemo(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type RunDemoQueryResult = NonNullable< + Awaited> +>; +export type RunDemoQueryError = ErrorType; + +/** + * @summary Free demo (rate-limited) + */ + +export function useRunDemo< + TData = Awaited>, + TError = ErrorType, +>( + params: RunDemoParams, + options?: { + query?: UseQueryOptions>, TError, TData>; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getRunDemoQueryOptions(params, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index bbcb269..21fefdd 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index fee3c58..868f397 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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(), +}); diff --git a/lib/api-zod/src/generated/types/createJobRequest.ts b/lib/api-zod/src/generated/types/createJobRequest.ts new file mode 100644 index 0000000..15b1d8f --- /dev/null +++ b/lib/api-zod/src/generated/types/createJobRequest.ts @@ -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; +} diff --git a/lib/api-zod/src/generated/types/createJobResponse.ts b/lib/api-zod/src/generated/types/createJobResponse.ts new file mode 100644 index 0000000..f1c62b0 --- /dev/null +++ b/lib/api-zod/src/generated/types/createJobResponse.ts @@ -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; +} diff --git a/lib/api-zod/src/generated/types/demoResponse.ts b/lib/api-zod/src/generated/types/demoResponse.ts new file mode 100644 index 0000000..acceb16 --- /dev/null +++ b/lib/api-zod/src/generated/types/demoResponse.ts @@ -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; +} diff --git a/lib/api-zod/src/generated/types/errorResponse.ts b/lib/api-zod/src/generated/types/errorResponse.ts new file mode 100644 index 0000000..8c46a3a --- /dev/null +++ b/lib/api-zod/src/generated/types/errorResponse.ts @@ -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; +} diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index c816559..7e069df 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -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"; diff --git a/lib/api-zod/src/generated/types/invoiceInfo.ts b/lib/api-zod/src/generated/types/invoiceInfo.ts new file mode 100644 index 0000000..1370753 --- /dev/null +++ b/lib/api-zod/src/generated/types/invoiceInfo.ts @@ -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; +} diff --git a/lib/api-zod/src/generated/types/jobState.ts b/lib/api-zod/src/generated/types/jobState.ts new file mode 100644 index 0000000..b4c113c --- /dev/null +++ b/lib/api-zod/src/generated/types/jobState.ts @@ -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; diff --git a/lib/api-zod/src/generated/types/jobStatusResponse.ts b/lib/api-zod/src/generated/types/jobStatusResponse.ts new file mode 100644 index 0000000..ba35971 --- /dev/null +++ b/lib/api-zod/src/generated/types/jobStatusResponse.ts @@ -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; +} diff --git a/lib/api-zod/src/generated/types/runDemoParams.ts b/lib/api-zod/src/generated/types/runDemoParams.ts new file mode 100644 index 0000000..e7bbdac --- /dev/null +++ b/lib/api-zod/src/generated/types/runDemoParams.ts @@ -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; +}; diff --git a/replit.md b/replit.md index 15d028e..8d1cde6 100644 --- a/replit.md +++ b/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/" + +# 3. (Stub mode only) Mark eval invoice paid +curl -s -X POST "$BASE/api/dev/stub/pay/" + +# 4. Poll again — auto-advances to awaiting_work_payment, returns work invoice +curl -s "$BASE/api/jobs/" + +# 5. (Stub mode only) Mark work invoice paid +curl -s -X POST "$BASE/api/dev/stub/pay/" + +# 6. Poll again — auto-advances to complete, returns result +curl -s "$BASE/api/jobs/" +# → {"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