diff --git a/package.json b/package.json index ee020b1a..bb371809 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 4888fffe..b3e481ad 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -27,6 +27,11 @@ const migrations: Migration[] = [ name: "local-agent-effort-rename", up: migrateLocalAgentEffortRename, }, + { + version: 5, + name: "workflow-journal", + up: migrateWorkflowJournal, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -193,6 +198,90 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { sqlite.exec("alter table local_agent_sessions rename column thinking to effort"); } +function migrateWorkflowJournal(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workflow_runs ( + id text primary key, + name text not null, + source text not null, + script_path text not null, + script_hash text not null, + workspace_root text not null, + workspace_id text, + args_json text not null default 'null', + status text not null, + error text, + error_kind text, + result_json text, + pid integer, + heartbeat_at text, + cancel_requested text not null default 'false', + resumed_from_run_id text, + base_sha text, + created_at text not null, + started_at text, + completed_at text, + updated_at text not null + ); + + create index if not exists workflow_runs_status_updated_idx + on workflow_runs(status, updated_at desc); + + create index if not exists workflow_runs_workspace_updated_idx + on workflow_runs(workspace_root, updated_at desc); + + create index if not exists workflow_runs_heartbeat_idx + on workflow_runs(status, heartbeat_at); + + create index if not exists workflow_runs_resumed_from_idx + on workflow_runs(resumed_from_run_id); + + create table if not exists workflow_events ( + run_id text not null, + seq integer not null, + type text not null, + phase text, + label text, + data_json text not null default '{}', + created_at text not null, + primary key (run_id, seq), + foreign key (run_id) references workflow_runs(id) on delete cascade + ); + + create index if not exists workflow_events_run_seq_idx + on workflow_events(run_id, seq); + + create table if not exists workflow_agent_calls ( + run_id text not null, + call_index integer not null, + cache_key text not null, + provider text not null, + model text, + effort text, + label text, + phase text, + status text not null, + from_cache text not null default 'false', + provider_session_id text, + response_text text, + structured_json text, + error text, + isolation text not null default 'shared', + worktree_path text, + dirty text, + created_at text not null, + started_at text, + completed_at text, + updated_at text not null, + primary key (run_id, call_index), + foreign key (run_id) references workflow_runs(id) on delete cascade + ); + + create index if not exists workflow_agent_calls_cache_key_idx + on workflow_agent_calls(run_id, cache_key); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index cb46ecf1..36104a97 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -97,9 +97,97 @@ export const localAgentSessions = sqliteTable( ], ); +export const workflowRuns = sqliteTable( + "workflow_runs", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + source: text("source").notNull(), + scriptPath: text("script_path").notNull(), + scriptHash: text("script_hash").notNull(), + workspaceRoot: text("workspace_root").notNull(), + workspaceId: text("workspace_id"), + argsJson: text("args_json").notNull().default("null"), + status: text("status").notNull(), + error: text("error"), + errorKind: text("error_kind"), + resultJson: text("result_json"), + pid: integer("pid"), + heartbeatAt: text("heartbeat_at"), + cancelRequested: text("cancel_requested").notNull().default("false"), + resumedFromRunId: text("resumed_from_run_id"), + baseSha: text("base_sha"), + createdAt: text("created_at").notNull(), + startedAt: text("started_at"), + completedAt: text("completed_at"), + updatedAt: text("updated_at").notNull(), + }, + (table) => [ + index("workflow_runs_status_updated_idx").on(table.status, table.updatedAt), + index("workflow_runs_workspace_updated_idx").on(table.workspaceRoot, table.updatedAt), + index("workflow_runs_heartbeat_idx").on(table.status, table.heartbeatAt), + index("workflow_runs_resumed_from_idx").on(table.resumedFromRunId), + ], +); + +export const workflowEvents = sqliteTable( + "workflow_events", + { + runId: text("run_id") + .notNull() + .references(() => workflowRuns.id, { onDelete: "cascade" }), + seq: integer("seq").notNull(), + type: text("type").notNull(), + phase: text("phase"), + label: text("label"), + dataJson: text("data_json").notNull().default("{}"), + createdAt: text("created_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.seq] }), + index("workflow_events_run_seq_idx").on(table.runId, table.seq), + ], +); + +export const workflowAgentCalls = sqliteTable( + "workflow_agent_calls", + { + runId: text("run_id") + .notNull() + .references(() => workflowRuns.id, { onDelete: "cascade" }), + callIndex: integer("call_index").notNull(), + cacheKey: text("cache_key").notNull(), + provider: text("provider").notNull(), + model: text("model"), + effort: text("effort"), + label: text("label"), + phase: text("phase"), + status: text("status").notNull(), + fromCache: text("from_cache").notNull().default("false"), + providerSessionId: text("provider_session_id"), + responseText: text("response_text"), + structuredJson: text("structured_json"), + error: text("error"), + isolation: text("isolation").notNull().default("shared"), + worktreePath: text("worktree_path"), + dirty: text("dirty"), + createdAt: text("created_at").notNull(), + startedAt: text("started_at"), + completedAt: text("completed_at"), + updatedAt: text("updated_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.callIndex] }), + index("workflow_agent_calls_cache_key_idx").on(table.runId, table.cacheKey), + ], +); + export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; +export type WorkflowRunRow = typeof workflowRuns.$inferSelect; +export type WorkflowEventRow = typeof workflowEvents.$inferSelect; +export type WorkflowAgentCallRow = typeof workflowAgentCalls.$inferSelect; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 032f5c03..a5cfaec4 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -45,6 +45,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, { version: 4, name: "local-agent-effort-rename" }, + { version: 5, name: "workflow-journal" }, ]); } finally { database.close(); diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts new file mode 100644 index 00000000..9620cd1d --- /dev/null +++ b/src/workflow-sandbox.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { createStubBudget, type WorkflowMeta } from "./workflow-types.js"; +import { runWorkflowSandbox, WorkflowDeterminismError } from "./workflow-sandbox.js"; + +function api(meta: WorkflowMeta, logs?: string[]) { + return { + agent: async () => "", + parallel: async () => [], + pipeline: async () => [], + phase: () => {}, + log: (msg: unknown) => { + logs?.push(String(msg)); + }, + args: undefined as unknown, + budget: createStubBudget(), + workflow: async () => null, + meta, + }; +} + +{ + const logs: string[] = []; + const parsed = parseWorkflowScript(` +export const meta = { name: 'console-test', description: 'd' } +console.log('a', { b: 1 }) +console.warn('w') +return 'ok' +`); + const result = await runWorkflowSandbox({ parsed, api: api(parsed.meta, logs) }); + assert.equal(result, "ok"); + assert.equal(logs[0], 'a {"b":1}'); + assert.equal(logs[1], "w"); +} + +{ + const parsed = parseWorkflowScript(` +export const meta = { name: 'math-abs-ok', description: 'd' } +return Math.abs(-3) +`); + const abs = await runWorkflowSandbox({ parsed, api: api(parsed.meta) }); + assert.equal(abs, 3); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'fetch-ban', description: 'd' } +return fetch('https://example.com') +`), + api: api({ name: "fetch-ban", description: "d" }), + }), + /fetch is not defined|ReferenceError/, + ); +} + +{ + const parsed = parseWorkflowScript(` +export const meta = { name: 'budget', description: 'd' } +return { total: budget.total, spent: budget.spent(), remaining: budget.remaining() } +`); + const budgetResult = await runWorkflowSandbox({ parsed, api: api(parsed.meta) }); + assert.deepEqual(budgetResult, { total: null, spent: 0, remaining: Infinity }); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'rnd', description: 'd' } +return Math.random() +`), + api: api({ name: "rnd", description: "d" }), + }), + (error: unknown) => + error instanceof WorkflowDeterminismError && /Math\.random/.test(error.message), + ); +} + +console.log("workflow-sandbox.test.ts: ok"); diff --git a/src/workflow-sandbox.ts b/src/workflow-sandbox.ts new file mode 100644 index 00000000..f05a17f1 --- /dev/null +++ b/src/workflow-sandbox.ts @@ -0,0 +1,216 @@ +import vm from "node:vm"; +import type { ParsedWorkflowScript } from "./workflow-script.js"; +import type { WorkflowBudget, WorkflowMeta } from "./workflow-types.js"; + +export class WorkflowDeterminismError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkflowDeterminismError"; + } +} + +export interface WorkflowSandboxApi { + agent: (...args: unknown[]) => unknown; + parallel: (...args: unknown[]) => unknown; + pipeline: (...args: unknown[]) => unknown; + phase: (...args: unknown[]) => unknown; + log: (...args: unknown[]) => unknown; + args: unknown; + budget: WorkflowBudget; + workflow: (...args: unknown[]) => unknown; + /** Host bookkeeping only; script binds its own `const meta`. */ + meta: WorkflowMeta; +} + +export interface RunWorkflowSandboxOptions { + parsed: ParsedWorkflowScript; + api: WorkflowSandboxApi; + /** Host wall-clock max for the whole script (ms). Default 6h. */ + timeoutMs?: number; +} + +/** + * Execute a compiled workflow script in a restricted node:vm context. + * Not a hostile multi-tenant security boundary — determinism + capability reduction. + */ +export async function runWorkflowSandbox( + options: RunWorkflowSandboxOptions, +): Promise { + const { parsed, api } = options; + const timeoutMs = options.timeoutMs ?? 6 * 60 * 60 * 1000; + + const consoleProxy = { + log: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + warn: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + error: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + info: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + debug: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + }; + + // Script params: host APIs only. `meta`/`console` are not params (meta is script-local; + // console lives on sandbox globals so console.log works). + const sandboxApi = { + agent: api.agent, + parallel: api.parallel, + pipeline: api.pipeline, + phase: api.phase, + log: api.log, + args: api.args, + budget: api.budget, + workflow: api.workflow, + }; + + const context = vm.createContext(createSandboxGlobals(consoleProxy)); + const factory = parsed.script.runInContext(context, { + timeout: 5_000, + displayErrors: true, + }) as (api: typeof sandboxApi) => Promise; + + if (typeof factory !== "function") { + throw new Error("Workflow script did not compile to a function"); + } + + const result = await withTimeout( + Promise.resolve().then(() => factory(sandboxApi)), + timeoutMs, + ); + // Context objects keep the sandbox realm's prototypes; rehydrate for host use. + return rehydrateHostValue(result); +} + +/** Copy a sandbox value into the host realm (plain objects / arrays / primitives). */ +export function rehydrateHostValue(value: unknown): unknown { + if (value === null || value === undefined) return value; + const t = typeof value; + if (t === "string" || t === "number" || t === "boolean" || t === "bigint") return value; + if (t === "function" || t === "symbol") return value; + if (Array.isArray(value)) { + return Array.from(value as unknown[], (item) => rehydrateHostValue(item)); + } + if (value instanceof Date) { + return new Date(value.getTime()); + } + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + out[key] = rehydrateHostValue(entry); + } + return out; +} + +function createSandboxGlobals( + consoleProxy: Record void>, +): Record { + return { + Object, + Array, + String, + Number, + Boolean, + Map, + Set, + WeakMap, + WeakSet, + JSON, + Math: createBannedMath(), + Date: createBannedDate(), + RegExp, + Error, + TypeError, + RangeError, + SyntaxError, + URIError, + EvalError, + Promise, + Symbol, + Proxy, + Reflect, + parseInt, + parseFloat, + isNaN, + isFinite, + encodeURI, + decodeURI, + encodeURIComponent, + decodeURIComponent, + undefined, + NaN, + Infinity, + console: consoleProxy, + // Explicitly absent: require, process, fetch, Buffer, setTimeout, setInterval, ... + }; +} + +function createBannedDate(): typeof Date { + const RealDate = Date; + + function DateShim(this: unknown, ...args: unknown[]): string | Date { + if (new.target) { + if (args.length === 0) { + throw new WorkflowDeterminismError( + "new Date() without arguments is banned in workflow scripts (pass an ISO string)", + ); + } + return new (RealDate as unknown as new (...a: unknown[]) => Date)(...args); + } + throw new WorkflowDeterminismError("Date() is banned in workflow scripts"); + } + + DateShim.now = function bannedNow(): number { + throw new WorkflowDeterminismError("Date.now() is banned in workflow scripts"); + }; + DateShim.parse = RealDate.parse.bind(RealDate); + DateShim.UTC = RealDate.UTC.bind(RealDate); + Object.setPrototypeOf(DateShim, RealDate); + DateShim.prototype = RealDate.prototype; + return DateShim as unknown as typeof Date; +} + +function createBannedMath(): Math { + return new Proxy(Math, { + get(target, prop, receiver) { + if (prop === "random") { + return () => { + throw new WorkflowDeterminismError("Math.random() is banned in workflow scripts"); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +function stringifyConsoleArg(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Workflow script exceeded host timeout (${ms}ms)`)); + }, ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts new file mode 100644 index 00000000..c8e61423 --- /dev/null +++ b/src/workflow-script.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript, WorkflowScriptError } from "./workflow-script.js"; +import { createStubBudget } from "./workflow-types.js"; +import { runWorkflowSandbox, WorkflowDeterminismError } from "./workflow-sandbox.js"; + +{ + const parsed = parseWorkflowScript(` +export const meta = { + name: 'fanout-review', + description: 'Two reviewers', + phases: [{ title: 'Review', detail: 'parallel' }], + defaultProvider: 'codex', + concurrency: 4, +} + +return { ok: true, name: meta.name } +`); + assert.equal(parsed.meta.name, "fanout-review"); + assert.equal(parsed.meta.description, "Two reviewers"); + assert.equal(parsed.meta.defaultProvider, "codex"); + assert.equal(parsed.meta.concurrency, 4); + assert.deepEqual(parsed.meta.phases, [{ title: "Review", detail: "parallel" }]); + assert.match(parsed.scriptHash, /^[a-f0-9]{64}$/); +} + +{ + assert.throws( + () => parseWorkflowScript(`const x = 1; export const meta = { name: 'a', description: 'b' }`), + (error: unknown) => + error instanceof WorkflowScriptError && + error.kind === "meta" && + /first statement/.test(error.message), + ); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { + name: 'bad', + description: 'x', + concurrency: Math.max(1, 2), +} +`), + (error: unknown) => error instanceof WorkflowScriptError && error.kind === "meta", + ); +} + +{ + assert.throws( + () => parseWorkflowScript(`export const meta = { name: 'Bad_Name', description: 'x' }`), + /meta\.name must match/, + ); +} + +{ + assert.throws( + () => parseWorkflowScript(`export const meta = { description: 'only' }`), + /meta\.name is required/, + ); +} + +{ + // Leading comments OK + const parsed = parseWorkflowScript(`// header +/* block */ +export const meta = { name: 'ok', description: 'd' } +return 1 +`); + assert.equal(parsed.meta.name, "ok"); +} + +async function runBody(source: string): Promise { + const parsed = parseWorkflowScript(source); + const logs: string[] = []; + return runWorkflowSandbox({ + parsed, + api: { + agent: async () => "agent-result", + parallel: async (...args: unknown[]) => { + const thunks = args[0] as Array<() => Promise>; + return Promise.all(thunks.map((t) => t().catch(() => null))); + }, + pipeline: async (...args: unknown[]) => args[0], + phase: () => {}, + log: (msg: unknown) => { + logs.push(String(msg)); + }, + args: { n: 1 }, + budget: createStubBudget(), + workflow: async () => null, + meta: parsed.meta, + }, + }); +} + +{ + const result = await runBody(` +export const meta = { name: 'ret', description: 'd' } +phase('A') +log('hi ' + args.n) +return { v: 1 + 1, fromAgent: await agent('p') } +`); + assert.deepEqual(result, { v: 2, fromAgent: "agent-result" }); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'now', description: 'd' } +return Date.now() +`), + (error: unknown) => + error instanceof WorkflowDeterminismError && /Date\.now/.test(error.message), + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'rand', description: 'd' } +return Math.random() +`), + WorkflowDeterminismError, + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'date', description: 'd' } +return new Date() +`), + WorkflowDeterminismError, + ); +} + +{ + // Fixed Date is OK + const result = await runBody(` +export const meta = { name: 'fixed-date', description: 'd' } +return new Date('2020-01-01T00:00:00.000Z').toISOString() +`); + assert.equal(result, "2020-01-01T00:00:00.000Z"); +} + +{ + // No process/require + await assert.rejects( + () => + runBody(` +export const meta = { name: 'proc', description: 'd' } +return process.pid +`), + /process is not defined|ReferenceError/, + ); +} + +console.log("workflow-script.test.ts: ok"); diff --git a/src/workflow-script.ts b/src/workflow-script.ts new file mode 100644 index 00000000..5847f08a --- /dev/null +++ b/src/workflow-script.ts @@ -0,0 +1,280 @@ +import { createHash } from "node:crypto"; +import vm from "node:vm"; +import { + LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, + isLocalAgentProvider, +} from "./local-agent-profiles.js"; +import { WORKFLOW_LIMITS, type WorkflowMeta } from "./workflow-types.js"; + +export class WorkflowScriptError extends Error { + constructor( + readonly kind: "syntax" | "meta" | "script_too_large", + message: string, + readonly line?: number, + ) { + super(message); + this.name = "WorkflowScriptError"; + } +} + +export interface ParsedWorkflowScript { + meta: WorkflowMeta; + source: string; + scriptHash: string; + /** Compiled async factory: (api) => Promise */ + script: vm.Script; + filename: string; +} + +const META_EXPORT = /export\s+const\s+meta\s*=/; +const NAME_RE = /^[a-z0-9-]+$/; + +/** + * Parse + compile a workflow script. + * Expects `export const meta = {…}` as the first statement (optional leading comments/blank). + */ +export function parseWorkflowScript( + source: string, + options: { filename?: string } = {}, +): ParsedWorkflowScript { + if (Buffer.byteLength(source, "utf8") > WORKFLOW_LIMITS.scriptSourceBytes) { + throw new WorkflowScriptError( + "script_too_large", + `Script exceeds ${WORKFLOW_LIMITS.scriptSourceBytes} bytes`, + ); + } + + const filename = options.filename ?? "workflow:inline"; + const normalized = source.replace(/^/, ""); + const { metaLiteral } = extractMetaLiteral(normalized); + const meta = validateMeta(evaluateMetaLiteral(metaLiteral, filename)); + + // Strip only the leading `export ` so line numbers stay aligned (7 spaces). + const body = normalized.replace(META_EXPORT, " const meta ="); + + // Reject further imports / exports after transform + if (/\bimport\s+/.test(body) || /\bexport\s+/.test(body)) { + throw new WorkflowScriptError( + "syntax", + "Workflow scripts may not use import or additional export statements", + ); + } + + // Inject host APIs as params. `meta` stays as the script's own `const meta` + // (would TDZ/redeclare if also injected). `console` lives on the sandbox globals. + const wrapped = `(async ({ agent, parallel, pipeline, phase, log, args, budget, workflow }) => {\n${body}\n})`; + let script: vm.Script; + try { + script = new vm.Script(wrapped, { + filename, + // Outer async wrapper adds one line before user source + lineOffset: -1, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const line = parseErrorLine(message); + throw new WorkflowScriptError("syntax", message, line); + } + + return { + meta, + source: normalized, + scriptHash: hashSource(normalized), + script, + filename, + }; +} + +export function hashSource(source: string): string { + return createHash("sha256").update(source).digest("hex"); +} + +function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex: number } { + const match = META_EXPORT.exec(source); + if (!match || match.index === undefined) { + throw new WorkflowScriptError( + "meta", + "Workflow script must start with `export const meta = { … }`", + ); + } + + // Ensure only whitespace/comments before export + const before = source.slice(0, match.index); + if (!isOnlyPreamble(before)) { + throw new WorkflowScriptError( + "meta", + "`export const meta` must be the first statement (comments/blank lines OK)", + ); + } + + const afterAssign = source.slice(match.index + match[0].length); + const trimmedStart = afterAssign.match(/^\s*/)?.[0].length ?? 0; + const objectStart = match.index + match[0].length + trimmedStart; + if (source[objectStart] !== "{") { + throw new WorkflowScriptError("meta", "meta value must be an object literal `{…}`"); + } + + const end = scanBalancedObject(source, objectStart); + const metaLiteral = source.slice(objectStart, end + 1); + + // Purity: no calls, spreads, templates inside meta (rough static checks) + if (/[`$]/.test(metaLiteral) && /\$\{/.test(metaLiteral)) { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no template interpolation)"); + } + if (/\.\.\./.test(metaLiteral)) { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no spreads)"); + } + // Disallow identifier references that look like calls: word( + if (/\b[A-Za-z_$][\w$]*\s*\(/.test(metaLiteral)) { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no function calls)"); + } + + return { metaLiteral, metaEndIndex: end + 1 }; +} + +function scanBalancedObject(source: string, start: number): number { + let depth = 0; + let inString: '"' | "'" | null = null; + let escape = false; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]!; + if (inString) { + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === "{") depth += 1; + else if (ch === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + throw new WorkflowScriptError("meta", "Unclosed meta object literal"); +} + +function isOnlyPreamble(text: string): boolean { + // strip block comments, line comments, whitespace + const stripped = text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, "") + .trim(); + return stripped.length === 0; +} + +function evaluateMetaLiteral(literal: string, filename: string): unknown { + try { + const value = vm.runInNewContext(`(${literal})`, Object.create(null), { + filename: `${filename}:meta`, + timeout: 1000, + }); + // Rehydrate into the host realm — vm values keep context prototypes which + // break assert.deepEqual and other host identity checks. + return JSON.parse(JSON.stringify(value)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new WorkflowScriptError("meta", `Invalid meta literal: ${message}`); + } +} + +function validateMeta(value: unknown): WorkflowMeta { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new WorkflowScriptError("meta", "meta must be an object"); + } + const record = value as Record; + const name = readRequiredString(record, "name"); + if (!NAME_RE.test(name)) { + throw new WorkflowScriptError( + "meta", + `meta.name must match ${NAME_RE} (got ${JSON.stringify(name)})`, + ); + } + const description = readRequiredString(record, "description"); + + let phases: WorkflowMeta["phases"]; + if (record.phases !== undefined) { + if (!Array.isArray(record.phases)) { + throw new WorkflowScriptError("meta", "meta.phases must be an array"); + } + const hostPhases: NonNullable = []; + for (let index = 0; index < record.phases.length; index += 1) { + const phase = record.phases[index]; + if (!phase || typeof phase !== "object" || Array.isArray(phase)) { + throw new WorkflowScriptError("meta", `meta.phases[${index}] must be an object`); + } + const p = phase as Record; + const title = readRequiredString(p, "title", `meta.phases[${index}].title`); + const detail = optionalString(p.detail); + hostPhases.push(detail === undefined ? { title } : { title, detail }); + } + phases = hostPhases; + } + + const whenToUse = optionalString(record.whenToUse); + let defaultProvider: LocalAgentProvider | undefined; + if (record.defaultProvider !== undefined) { + if (typeof record.defaultProvider !== "string" || !isLocalAgentProvider(record.defaultProvider)) { + throw new WorkflowScriptError( + "meta", + `meta.defaultProvider must be one of: ${LOCAL_AGENT_PROVIDERS.join(", ")}`, + ); + } + defaultProvider = record.defaultProvider; + } + + let concurrency: number | undefined; + if (record.concurrency !== undefined) { + if (typeof record.concurrency !== "number" || !Number.isFinite(record.concurrency)) { + throw new WorkflowScriptError("meta", "meta.concurrency must be a number"); + } + concurrency = Math.floor(record.concurrency); + if (concurrency < 1) { + throw new WorkflowScriptError("meta", "meta.concurrency must be >= 1"); + } + } + + return { + name, + description, + ...(phases ? { phases } : {}), + ...(whenToUse ? { whenToUse } : {}), + ...(defaultProvider ? { defaultProvider } : {}), + ...(concurrency !== undefined ? { concurrency } : {}), + }; +} + +function readRequiredString( + record: Record, + key: string, + label = `meta.${key}`, +): string { + const value = record[key]; + if (typeof value !== "string" || !value.trim()) { + throw new WorkflowScriptError("meta", `${label} is required`); + } + return value.trim(); +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseErrorLine(message: string): number | undefined { + const match = message.match(/:(\d+)(?::\d+)?\)?$/m) ?? message.match(/line\s+(\d+)/i); + if (!match) return undefined; + const n = Number(match[1]); + return Number.isFinite(n) ? n : undefined; +} diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts new file mode 100644 index 00000000..f436a5ca --- /dev/null +++ b/src/workflow-store.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openDatabase } from "./db/client.js"; +import { WorkflowStore } from "./workflow-store.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-workflow-store-test-")); +const stores: WorkflowStore[] = []; + +try { + const store = new WorkflowStore(root); + stores.push(store); + + const run = store.createRun({ + name: "fanout", + source: "inline", + scriptPath: join(root, "runs", "wfr_test.js"), + scriptHash: "abc123", + workspaceRoot: join(root, "project"), + workspaceId: "ws_1", + argsJson: JSON.stringify({ files: ["a.ts"] }), + }); + + assert.match(run.id, /^wfr_[a-f0-9]{12}$/); + assert.equal(run.status, "starting"); + assert.equal(run.cancelRequested, false); + assert.equal(store.getRun(run.id)?.name, "fanout"); + + const claimed = store.claimRun(run.id, process.pid); + assert.equal(claimed?.status, "running"); + assert.equal(claimed?.pid, process.pid); + assert.ok(claimed?.startedAt); + assert.equal(store.claimRun(run.id, 99999), undefined); + + store.setHeartbeat(run.id); + assert.ok(store.getRun(run.id)?.heartbeatAt); + + const e1 = store.appendEvent({ runId: run.id, type: "run_started", data: { ok: true } }); + const e2 = store.appendEvent({ + runId: run.id, + type: "phase_started", + phase: "Review", + label: "r1", + }); + const e3 = store.appendEvent({ runId: run.id, type: "log", data: { message: "hello" } }); + assert.equal(e1.seq, 1); + assert.equal(e2.seq, 2); + assert.equal(e3.seq, 3); + + const page1 = store.drainEvents(run.id, 0, 2); + assert.equal(page1.events.length, 2); + assert.equal(page1.nextSeq, 2); + assert.equal(page1.terminal, false); + + const page2 = store.drainEvents(run.id, 2, 10); + assert.equal(page2.events.length, 1); + assert.equal(page2.events[0]?.seq, 3); + assert.equal(page2.nextSeq, 3); + + store.beginAgentCall({ + runId: run.id, + callIndex: 0, + cacheKey: "key-a", + provider: "codex", + model: "gpt-5.4", + effort: "high", + phase: "Review", + isolation: "worktree", + worktreePath: "/tmp/wt", + }); + store.completeAgentCall({ + runId: run.id, + callIndex: 0, + responseText: "done", + structuredJson: JSON.stringify({ ok: true }), + providerSessionId: "sess_1", + dirty: true, + }); + const call = store.getAgentCall(run.id, 0); + assert.equal(call?.status, "completed"); + assert.equal(call?.isolation, "worktree"); + assert.equal(call?.dirty, true); + assert.equal(call?.providerSessionId, "sess_1"); + assert.equal(call?.effort, "high"); + + store.beginAgentCall({ + runId: run.id, + callIndex: 1, + cacheKey: "key-b", + provider: "claude", + }); + store.failAgentCall({ runId: run.id, callIndex: 1, error: "boom" }); + assert.equal(store.getAgentCall(run.id, 1)?.status, "failed"); + assert.equal(store.listAgentCalls(run.id).length, 2); + + const cancelled = store.requestCancel(run.id); + assert.equal(cancelled.cancelRequested, true); + assert.equal(store.isCancelRequested(run.id), true); + + const terminal = store.cancelRun(run.id); + assert.equal(terminal.status, "cancelled"); + assert.equal(terminal.errorKind, "cancelled"); + assert.equal(store.cancelRun(run.id).status, "cancelled"); + + const drainDone = store.drainEvents(run.id, 0, 100); + assert.equal(drainDone.terminal, true); + + const run2 = store.createRun({ + name: "done", + source: "named", + scriptPath: join(root, "x.js"), + scriptHash: "h2", + workspaceRoot: join(root, "project"), + }); + store.claimRun(run2.id, process.pid); + store.completeRun(run2.id, { resultJson: JSON.stringify({ ok: 1 }) }); + assert.equal(store.getRun(run2.id)?.status, "completed"); + assert.equal(store.getRun(run2.id)?.resultJson, JSON.stringify({ ok: 1 })); + + // Reap: stale heartbeat + dead pid (force heartbeat via shared sqlite handle) + const run3 = store.createRun({ + name: "stale", + source: "inline", + scriptPath: join(root, "s.js"), + scriptHash: "h3", + workspaceRoot: join(root, "project"), + }); + store.claimRun(run3.id, 2_147_483_646); + const db = openDatabase(root); + try { + db.sqlite + .prepare(`update workflow_runs set heartbeat_at = ? where id = ?`) + .run(new Date(Date.now() - 120_000).toISOString(), run3.id); + } finally { + db.close(); + } + const reaped = store.reapStale(60_000); + assert.ok(reaped.some((r) => r.id === run3.id && r.status === "failed")); + assert.equal(store.getRun(run3.id)?.errorKind, "heartbeat"); + + const run4 = store.createRun({ + name: "seq", + source: "inline", + scriptPath: join(root, "seq.js"), + scriptHash: "h4", + workspaceRoot: join(root, "project"), + }); + const seqs = [0, 1, 2, 3, 4].map(() => + store.appendEvent({ runId: run4.id, type: "log", data: { n: 1 } }).seq, + ); + assert.deepEqual(seqs, [1, 2, 3, 4, 5]); + + assert.ok(store.listRuns().length >= 3); + + // Second store instance sees same rows + const other = new WorkflowStore(root); + stores.push(other); + assert.equal(other.getRun(run.id)?.status, "cancelled"); +} finally { + for (const store of stores) store.close(); + rmSync(root, { recursive: true, force: true }); +} + +console.log("workflow-store.test.ts: ok"); diff --git a/src/workflow-store.ts b/src/workflow-store.ts new file mode 100644 index 00000000..da29c711 --- /dev/null +++ b/src/workflow-store.ts @@ -0,0 +1,654 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import type { ServerConfig } from "./config.js"; +import { + WORKFLOW_LIMITS, + type AgentIsolationMode, + type WorkflowAgentCallRecord, + type WorkflowAgentCallStatus, + type WorkflowErrorKind, + type WorkflowEventRecord, + type WorkflowEventType, + type WorkflowRunRecord, + type WorkflowRunSource, + type WorkflowRunStatus, +} from "./workflow-types.js"; + +export interface CreateWorkflowRunInput { + name: string; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + workspaceId?: string; + argsJson?: string; + resumedFromRunId?: string; + baseSha?: string; +} + +export interface AppendWorkflowEventInput { + runId: string; + type: WorkflowEventType; + phase?: string; + label?: string; + data?: unknown; +} + +export interface BeginAgentCallInput { + runId: string; + callIndex: number; + cacheKey: string; + provider: string; + model?: string; + effort?: string; + label?: string; + phase?: string; + isolation?: AgentIsolationMode; + worktreePath?: string; +} + +export interface CompleteAgentCallInput { + runId: string; + callIndex: number; + responseText?: string; + structuredJson?: string; + providerSessionId?: string; + dirty?: boolean; + worktreePath?: string; + fromCache?: boolean; +} + +export interface FailAgentCallInput { + runId: string; + callIndex: number; + error: string; + worktreePath?: string; + dirty?: boolean; +} + +export interface CompleteRunInput { + resultJson?: string; +} + +export interface FailRunInput { + error: string; + errorKind?: WorkflowErrorKind; +} + +export interface DrainEventsResult { + events: WorkflowEventRecord[]; + nextSeq: number; + terminal: boolean; + run: WorkflowRunRecord; +} + +interface WorkflowRunRow { + id: string; + name: string; + source: string; + script_path: string; + script_hash: string; + workspace_root: string; + workspace_id: string | null; + args_json: string; + status: string; + error: string | null; + error_kind: string | null; + result_json: string | null; + pid: number | null; + heartbeat_at: string | null; + cancel_requested: string; + resumed_from_run_id: string | null; + base_sha: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; + updated_at: string; +} + +interface WorkflowEventRow { + run_id: string; + seq: number; + type: string; + phase: string | null; + label: string | null; + data_json: string; + created_at: string; +} + +interface WorkflowAgentCallRow { + run_id: string; + call_index: number; + cache_key: string; + provider: string; + model: string | null; + effort: string | null; + label: string | null; + phase: string | null; + status: string; + from_cache: string; + provider_session_id: string | null; + response_text: string | null; + structured_json: string | null; + error: string | null; + isolation: string; + worktree_path: string | null; + dirty: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; + updated_at: string; +} + +const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); + +export class WorkflowStore { + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + } + + createRun(input: CreateWorkflowRunInput): WorkflowRunRecord { + const now = isoNow(); + const argsJson = input.argsJson ?? "null"; + assertArgsSize(argsJson); + + const record: WorkflowRunRecord = { + id: `wfr_${randomUUID().replaceAll("-", "").slice(0, 12)}`, + name: input.name, + source: input.source, + scriptPath: input.scriptPath, + scriptHash: input.scriptHash, + workspaceRoot: resolve(input.workspaceRoot), + workspaceId: input.workspaceId, + argsJson, + status: "starting", + cancelRequested: false, + resumedFromRunId: input.resumedFromRunId, + baseSha: input.baseSha, + createdAt: now, + updatedAt: now, + }; + + this.database.sqlite + .prepare( + `insert into workflow_runs ( + id, name, source, script_path, script_hash, workspace_root, workspace_id, + args_json, status, cancel_requested, resumed_from_run_id, base_sha, + created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + record.id, + record.name, + record.source, + record.scriptPath, + record.scriptHash, + record.workspaceRoot, + record.workspaceId ?? null, + record.argsJson, + record.status, + "false", + record.resumedFromRunId ?? null, + record.baseSha ?? null, + record.createdAt, + record.updatedAt, + ); + + return record; + } + + getRun(id: string): WorkflowRunRecord | undefined { + const row = this.database.sqlite + .prepare("select * from workflow_runs where id = ?") + .get(id) as WorkflowRunRow | undefined; + return row ? rowToRun(row) : undefined; + } + + listRuns(limit = 50): WorkflowRunRecord[] { + const rows = this.database.sqlite + .prepare("select * from workflow_runs order by updated_at desc limit ?") + .all(Math.max(1, Math.min(limit, 500))) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + /** + * Atomically claim a starting run for the worker. + * Returns undefined if the run is missing or not claimable. + */ + claimRun(id: string, pid: number): WorkflowRunRecord | undefined { + const now = isoNow(); + const result = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'running', + pid = ?, + heartbeat_at = ?, + started_at = coalesce(started_at, ?), + updated_at = ? + where id = ? and status = 'starting'`, + ) + .run(pid, now, now, now, id); + if (result.changes === 0) return undefined; + return this.getRun(id); + } + + setHeartbeat(id: string, at = isoNow()): void { + this.database.sqlite + .prepare( + `update workflow_runs set heartbeat_at = ?, updated_at = ? where id = ? and status = 'running'`, + ) + .run(at, at, id); + } + + requestCancel(id: string): WorkflowRunRecord { + const run = this.requireRun(id); + if (TERMINAL_STATUSES.has(run.status)) return run; + + const now = isoNow(); + this.database.sqlite + .prepare( + `update workflow_runs set cancel_requested = 'true', updated_at = ? where id = ?`, + ) + .run(now, id); + return this.requireRun(id); + } + + isCancelRequested(id: string): boolean { + return this.requireRun(id).cancelRequested; + } + + completeRun(id: string, input: CompleteRunInput = {}): WorkflowRunRecord { + if (input.resultJson !== undefined) assertResultSize(input.resultJson); + const now = isoNow(); + const result = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'completed', + result_json = ?, + completed_at = ?, + updated_at = ?, + error = null, + error_kind = null + where id = ? and status in ('starting', 'running')`, + ) + .run(input.resultJson ?? null, now, now, id); + if (result.changes === 0) { + const run = this.requireRun(id); + if (TERMINAL_STATUSES.has(run.status)) return run; + throw new Error(`Cannot complete workflow run ${id} in status ${run.status}`); + } + return this.requireRun(id); + } + + failRun(id: string, input: FailRunInput): WorkflowRunRecord { + const now = isoNow(); + const result = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'failed', + error = ?, + error_kind = ?, + completed_at = ?, + updated_at = ? + where id = ? and status in ('starting', 'running')`, + ) + .run(input.error, input.errorKind ?? "internal", now, now, id); + if (result.changes === 0) { + const run = this.requireRun(id); + if (TERMINAL_STATUSES.has(run.status)) return run; + throw new Error(`Cannot fail workflow run ${id} in status ${run.status}`); + } + return this.requireRun(id); + } + + cancelRun(id: string, error = "cancelled"): WorkflowRunRecord { + const now = isoNow(); + const result = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'cancelled', + error = ?, + error_kind = 'cancelled', + cancel_requested = 'true', + completed_at = ?, + updated_at = ? + where id = ? and status in ('starting', 'running')`, + ) + .run(error, now, now, id); + if (result.changes === 0) { + const run = this.requireRun(id); + if (TERMINAL_STATUSES.has(run.status)) return run; + throw new Error(`Cannot cancel workflow run ${id} in status ${run.status}`); + } + return this.requireRun(id); + } + + appendEvent(input: AppendWorkflowEventInput): WorkflowEventRecord { + const dataJson = truncateJson(input.data ?? {}, WORKFLOW_LIMITS.eventDataJsonBytes); + const createdAt = isoNow(); + + const insert = this.database.sqlite.transaction(() => { + const next = this.database.sqlite + .prepare( + `select coalesce(max(seq), 0) + 1 as next_seq from workflow_events where run_id = ?`, + ) + .get(input.runId) as { next_seq: number }; + const seq = next.next_seq; + this.database.sqlite + .prepare( + `insert into workflow_events (run_id, seq, type, phase, label, data_json, created_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + input.runId, + seq, + input.type, + input.phase ?? null, + input.label ?? null, + dataJson, + createdAt, + ); + this.database.sqlite + .prepare(`update workflow_runs set updated_at = ? where id = ?`) + .run(createdAt, input.runId); + return seq; + }); + + const seq = insert(); + return { + runId: input.runId, + seq, + type: input.type, + phase: input.phase, + label: input.label, + dataJson, + createdAt, + }; + } + + drainEvents(runId: string, sinceSeq = 0, limit: number = WORKFLOW_LIMITS.eventDrainDefault): DrainEventsResult { + const run = this.requireRun(runId); + const capped = Math.max(1, Math.min(limit, WORKFLOW_LIMITS.eventDrainMax)); + const rows = this.database.sqlite + .prepare( + `select * from workflow_events + where run_id = ? and seq > ? + order by seq asc + limit ?`, + ) + .all(runId, sinceSeq, capped) as WorkflowEventRow[]; + const events = rows.map(rowToEvent); + const nextSeq = events.length > 0 ? events[events.length - 1]!.seq : sinceSeq; + return { + events, + nextSeq, + terminal: TERMINAL_STATUSES.has(run.status), + run, + }; + } + + beginAgentCall(input: BeginAgentCallInput): WorkflowAgentCallRecord { + const now = isoNow(); + const isolation: AgentIsolationMode = input.isolation === "worktree" ? "worktree" : "shared"; + this.database.sqlite + .prepare( + `insert into workflow_agent_calls ( + run_id, call_index, cache_key, provider, model, effort, label, phase, + status, from_cache, isolation, worktree_path, created_at, started_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, 'running', 'false', ?, ?, ?, ?, ?)`, + ) + .run( + input.runId, + input.callIndex, + input.cacheKey, + input.provider, + input.model ?? null, + input.effort ?? null, + input.label ?? null, + input.phase ?? null, + isolation, + input.worktreePath ?? null, + now, + now, + now, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + completeAgentCall(input: CompleteAgentCallInput): WorkflowAgentCallRecord { + if (input.responseText !== undefined) { + assertTextSize(input.responseText, WORKFLOW_LIMITS.responseTextBytes, "responseText"); + } + if (input.structuredJson !== undefined) { + assertTextSize(input.structuredJson, WORKFLOW_LIMITS.structuredJsonBytes, "structuredJson"); + } + const now = isoNow(); + const status: WorkflowAgentCallStatus = input.fromCache ? "from_cache" : "completed"; + this.database.sqlite + .prepare( + `update workflow_agent_calls set + status = ?, + from_cache = ?, + response_text = ?, + structured_json = ?, + provider_session_id = coalesce(?, provider_session_id), + worktree_path = coalesce(?, worktree_path), + dirty = ?, + completed_at = ?, + updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run( + status, + input.fromCache ? "true" : "false", + input.responseText ?? null, + input.structuredJson ?? null, + input.providerSessionId ?? null, + input.worktreePath ?? null, + input.dirty === undefined ? null : input.dirty ? "true" : "false", + now, + now, + input.runId, + input.callIndex, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + failAgentCall(input: FailAgentCallInput): WorkflowAgentCallRecord { + const now = isoNow(); + this.database.sqlite + .prepare( + `update workflow_agent_calls set + status = 'failed', + error = ?, + worktree_path = coalesce(?, worktree_path), + dirty = ?, + completed_at = ?, + updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run( + input.error, + input.worktreePath ?? null, + input.dirty === undefined ? null : input.dirty ? "true" : "false", + now, + now, + input.runId, + input.callIndex, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + getAgentCall(runId: string, callIndex: number): WorkflowAgentCallRecord | undefined { + const row = this.database.sqlite + .prepare(`select * from workflow_agent_calls where run_id = ? and call_index = ?`) + .get(runId, callIndex) as WorkflowAgentCallRow | undefined; + return row ? rowToAgentCall(row) : undefined; + } + + listAgentCalls(runId: string): WorkflowAgentCallRecord[] { + const rows = this.database.sqlite + .prepare( + `select * from workflow_agent_calls where run_id = ? order by call_index asc`, + ) + .all(runId) as WorkflowAgentCallRow[]; + return rows.map(rowToAgentCall); + } + + /** + * Mark running runs with a dead worker as failed. + * staleBeforeMs: heartbeat older than this AND pid not alive. + */ + reapStale(staleBeforeMs = 60_000, nowMs = Date.now()): WorkflowRunRecord[] { + const cutoff = new Date(nowMs - staleBeforeMs).toISOString(); + const candidates = this.database.sqlite + .prepare( + `select * from workflow_runs + where status = 'running' + and heartbeat_at is not null + and heartbeat_at < ?`, + ) + .all(cutoff) as WorkflowRunRow[]; + + const reaped: WorkflowRunRecord[] = []; + for (const row of candidates) { + if (row.pid !== null && isPidAlive(row.pid)) continue; + reaped.push( + this.failRun(row.id, { + error: "worker heartbeat lost", + errorKind: "heartbeat", + }), + ); + } + return reaped; + } + + close(): void { + this.database.close(); + } + + private requireRun(id: string): WorkflowRunRecord { + const run = this.getRun(id); + if (!run) throw new Error(`Unknown workflow run: ${id}`); + return run; + } + + private requireAgentCall(runId: string, callIndex: number): WorkflowAgentCallRecord { + const call = this.getAgentCall(runId, callIndex); + if (!call) throw new Error(`Unknown workflow agent call: ${runId}#${callIndex}`); + return call; + } +} + +export function createWorkflowStore(config: ServerConfig): WorkflowStore { + return new WorkflowStore(config.stateDir); +} + +function rowToRun(row: WorkflowRunRow): WorkflowRunRecord { + return { + id: row.id, + name: row.name, + source: row.source as WorkflowRunSource, + scriptPath: row.script_path, + scriptHash: row.script_hash, + workspaceRoot: row.workspace_root, + workspaceId: row.workspace_id ?? undefined, + argsJson: row.args_json, + status: row.status as WorkflowRunStatus, + error: row.error ?? undefined, + errorKind: (row.error_kind as WorkflowErrorKind | null) ?? undefined, + resultJson: row.result_json ?? undefined, + pid: row.pid ?? undefined, + heartbeatAt: row.heartbeat_at ?? undefined, + cancelRequested: row.cancel_requested === "true", + resumedFromRunId: row.resumed_from_run_id ?? undefined, + baseSha: row.base_sha ?? undefined, + createdAt: row.created_at, + startedAt: row.started_at ?? undefined, + completedAt: row.completed_at ?? undefined, + updatedAt: row.updated_at, + }; +} + +function rowToEvent(row: WorkflowEventRow): WorkflowEventRecord { + return { + runId: row.run_id, + seq: row.seq, + type: row.type as WorkflowEventType, + phase: row.phase ?? undefined, + label: row.label ?? undefined, + dataJson: row.data_json, + createdAt: row.created_at, + }; +} + +function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { + return { + runId: row.run_id, + callIndex: row.call_index, + cacheKey: row.cache_key, + provider: row.provider, + model: row.model ?? undefined, + effort: row.effort ?? undefined, + label: row.label ?? undefined, + phase: row.phase ?? undefined, + status: row.status as WorkflowAgentCallStatus, + fromCache: row.from_cache === "true", + providerSessionId: row.provider_session_id ?? undefined, + responseText: row.response_text ?? undefined, + structuredJson: row.structured_json ?? undefined, + error: row.error ?? undefined, + isolation: row.isolation === "worktree" ? "worktree" : "shared", + worktreePath: row.worktree_path ?? undefined, + dirty: row.dirty === null ? undefined : row.dirty === "true", + createdAt: row.created_at, + startedAt: row.started_at ?? undefined, + completedAt: row.completed_at ?? undefined, + updatedAt: row.updated_at, + }; +} + +function isoNow(): string { + return new Date().toISOString(); +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function assertArgsSize(argsJson: string): void { + assertTextSize(argsJson, WORKFLOW_LIMITS.argsJsonBytes, "argsJson"); +} + +function assertResultSize(resultJson: string): void { + assertTextSize(resultJson, WORKFLOW_LIMITS.resultJsonBytes, "resultJson"); +} + +function assertTextSize(value: string, maxBytes: number, label: string): void { + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > maxBytes) { + throw new Error(`${label} exceeds limit (${bytes} > ${maxBytes} bytes)`); + } +} + +function truncateJson(value: unknown, maxBytes: number): string { + let text: string; + try { + text = JSON.stringify(value) ?? "null"; + } catch { + text = JSON.stringify({ error: "unserializable" }); + } + if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; + const marker = JSON.stringify({ truncated: true }); + const budget = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8") - 32); + const slice = Buffer.from(text, "utf8").subarray(0, budget).toString("utf8"); + return JSON.stringify({ truncated: true, preview: slice }); +}