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

@@ -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;
};

View File

@@ -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 };
}

View File

@@ -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

View File

@@ -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(),
});

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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";

View 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;
}

View 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;

View 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;
}

View 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;
};