/** * Runs Route * * GET /runs - Get recent run history (supports query params: after, before, status, limit) * GET /runs/:executionId - Get specific run details * POST /runs/:executionId/cancel - Cancel a running execution * POST /runs/:executionId/continue - Continue execution after HITL intervention */ import { Hono } from "hono"; import type { Runtime, Action } from "@gizmo-ai/runtime"; import type { ErrorResponse, RunSummary, RunDetails, RunsResponse, StoredRun, } from "../types.ts"; import type { PersistenceStore } from "../persistence/index.ts"; /** * Cancel response */ export interface CancelResponse { status: "cancelled"; executionId: string; } /** * Continue response */ export interface ContinueResponse { status: "continued"; executionId: string; action: string; } /** * Continue request body */ export interface ContinueRequest { action: string; payload?: Record; } export interface RunsRouteConfig< S extends Record = Record, A extends Action = Action > { getRunHistory: () => Map; persistence?: PersistenceStore | null; runtime?: Runtime; } /** * Create the runs routes */ export function createRunsRoute< S extends Record, A extends Action >(config: RunsRouteConfig): Hono { const { getRunHistory, persistence, runtime } = config; const app = new Hono(); // GET /runs - List recent runs with optional filtering // Query params: after (timestamp), before (timestamp), status, limit app.get("/", async (c) => { try { const afterParam = c.req.query("after"); const beforeParam = c.req.query("before"); const statusParam = c.req.query("status") as "running" | "completed" | "failed" | undefined; const limitParam = c.req.query("limit"); const after = afterParam ? parseInt(afterParam, 10) : undefined; const before = beforeParam ? parseInt(beforeParam, 10) : undefined; const limit = limitParam ? parseInt(limitParam, 10) : undefined; // Try persistence first, fall back to in-memory if (persistence) { const records = await persistence.queryRuns({ after, before, status: statusParam, limit }); const runs: RunSummary[] = records.map(r => ({ executionId: r.executionId, startTime: r.startTime, endTime: r.endTime, status: r.status, actionCount: r.actionCount, error: r.error, digest: r.digest, })); return c.json({ runs }); } // In-memory fallback const history = getRunHistory(); let runs: RunSummary[] = Array.from(history.values()).map(run => ({ executionId: run.executionId, startTime: run.startTime, endTime: run.endTime, status: run.status, actionCount: run.actions.length, error: run.error, })); // Apply filters if (after) runs = runs.filter(r => r.startTime >= after); if (before) runs = runs.filter(r => r.startTime <= before); if (statusParam) runs = runs.filter(r => r.status === statusParam); // Sort by startTime descending (newest first) runs.sort((a, b) => b.startTime - a.startTime); // Apply limit if (limit && limit > 0) runs = runs.slice(0, limit); return c.json({ runs }); } catch (error) { return c.json( { error: `Failed to fetch runs: ${error instanceof Error ? error.message : String(error)}` }, 500 ); } }); // GET /runs/:executionId - Get specific run details app.get("/:executionId", async (c) => { try { const executionId = c.req.param("executionId"); // Try persistence first if (persistence) { const runRecord = await persistence.getRun(executionId); if (runRecord) { const actions = await persistence.getActions(executionId); const details: RunDetails = { executionId: runRecord.executionId, startTime: runRecord.startTime, endTime: runRecord.endTime, status: runRecord.status, actionCount: runRecord.actionCount, actions: actions.map(a => a.payload as Action), error: runRecord.error, digest: runRecord.digest, }; return c.json(details); } } // In-memory fallback const history = getRunHistory(); if (!history.has(executionId)) { return c.json( { error: `Run "${executionId}" not found` }, 404 ); } const run = history.get(executionId)!; const details: RunDetails = { executionId: run.executionId, startTime: run.startTime, endTime: run.endTime, status: run.status, actionCount: run.actions.length, actions: run.actions, error: run.error, }; return c.json(details); } catch (error) { return c.json( { error: `Failed to fetch run: ${error instanceof Error ? error.message : String(error)}` }, 500 ); } }); // POST /runs/:executionId/cancel - Cancel a running execution app.post("/:executionId/cancel", async (c) => { if (!runtime) { return c.json( { error: "Runtime not configured for control operations" }, 501 ); } try { const executionId = c.req.param("executionId"); // Check if the execution exists and is running const state = runtime.getState(); const execution = state.execution as { id: string | null; state: string; } | undefined; // Verify the execution ID matches the current execution if (execution?.id !== executionId) { return c.json( { error: `Execution "${executionId}" is not the current execution` }, 400 ); } if (execution.state !== "pending") { return c.json( { error: `Execution "${executionId}" is not running (state: ${execution.state})` }, 400 ); } // Dispatch the abort action const abortAction = { type: "RUNTIME_EXECUTION_ABORTED", payload: { executionId }, }; (runtime.dispatch as (action: Action) => void)(abortAction); return c.json({ status: "cancelled", executionId, }); } catch (error) { return c.json( { error: `Failed to cancel execution: ${error instanceof Error ? error.message : String(error)}` }, 500 ); } }); // POST /runs/:executionId/continue - Continue execution after HITL intervention app.post("/:executionId/continue", async (c) => { if (!runtime) { return c.json( { error: "Runtime not configured for control operations" }, 501 ); } let body: ContinueRequest; try { body = await c.req.json(); } catch (error) { return c.json( { error: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` }, 400 ); } if (!body.action || typeof body.action !== "string") { return c.json( { error: "Action type is required" }, 400 ); } try { const executionId = c.req.param("executionId"); const payload = { ...(body.payload ?? {}) }; const payloadExecutionId = payload.executionId; if (payloadExecutionId !== undefined && payloadExecutionId !== executionId) { return c.json( { error: `Payload executionId "${String(payloadExecutionId)}" does not match route executionId "${executionId}"` }, 400 ); } if (payloadExecutionId === undefined) { payload.executionId = executionId; } // Dispatch the continuation action (e.g., HITL_APPROVAL_GRANTED) const action = { type: body.action, payload, }; (runtime.dispatch as (action: Action) => void)(action); return c.json({ status: "continued", executionId, action: body.action, }); } catch (error) { return c.json( { error: `Failed to continue execution: ${error instanceof Error ? error.message : String(error)}` }, 500 ); } }); return app; }