#!/usr/bin/env bun /** * inline-run-setup — authoritative inline full-pipeline run identity (task 0804 R1). * * Unlike the subprocess path (`spur workflow run`), the interactive inline driver * allocated a run id but never persisted an authoritative `runs` row, so bound * `run.artifact` registration (0785 R3) correctly refused every inline record. This * script is the thin delegate the driver now runs at Run setup: it resolves the spur * repo checkout from the SPUR_BIN chain, imports the real app service * (`createOrAttachInlineRun` / `openInlineRunProjectDb` from packages/app), and lets it * resolve the SAME project-or-bundled definition the engine would launch, compute the * canonical definition digest with the exported hash machinery, and create-or-attach the * run row through the existing engine persistence adapter. * * The script itself contains NO direct SQL, NO second hasher and NO persistence policy — * every rule lives in packages/app (0804 D1). On a bundle-only install there is no repo * checkout to import the app service from, so the setup fails closed with actionable * remediation guidance (point SPUR_BIN at a repo checkout); it never falls back to an * unbound run (0804 R1 failure policy). * * Outcome JSON is written to `.spur/run/-inline-setup.json` so the driver can * seed the inline var overlay (`__runId`, `__definitionDigest`) that proof capture and * bound registration verify against. Exit 0 = authoritative identity ready (created or * idempotently attached); exit 1 = fail closed, the driver must stop. * * Repo-only script (ADR-065): it imports the app workspace source, so it runs under bun * against a monorepo checkout only — the same posture as task-size-precheck.ts and * task-evidence-precheck.ts. * * Usage: * bun plugins/sp/scripts/inline-run-setup.ts --run-id --file [--spur-bin ] * bun plugins/sp/scripts/inline-run-setup.ts --fingerprint --task-file [--feature-file ] [--spur-bin ] * bun plugins/sp/scripts/inline-run-setup.ts --action --run-id --node --kind \ * --status --ok --duration-ms [--spur-bin ] * bun plugins/sp/scripts/inline-run-setup.ts --close --run-id --status [--spur-bin ] * * The `--fingerprint` mode prints the engine's proof-input digest for the given spec files and * creates nothing (task 0862 R5). * * The `--action` / `--close` modes are the inline driver's ADR-117 emission boundary (task * 0868): `--action` records one completed action boundary as an `action_runs` row through the * shared `WorkflowActionTraceWriter`, `--close` marks the run row terminal through the same * writer. `--action` is best-effort: a persistence failure is appended to * `.spur/run/.log` and the script still exits 0 with `{"ok":false}` on stdout, so * observation never wedges the run. `--close` is NOT best-effort: the run-row closure is * bookkeeping, so a missing run row or a persistence failure exits 1 with a named error. * Exit 2 is reserved for usage errors. * * Env: SPUR_BIN */ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getEnvVar } from '../lib/env'; /** Outcome document written to `.spur/run/-inline-setup.json`. */ interface SetupOutcome { readonly ok: boolean; readonly runId: string; readonly attached?: boolean; readonly definitionDigest?: string; readonly workflowName?: string; readonly workflowVersion?: string | null; readonly resolvedPath?: string; readonly layer?: string; readonly workdir?: string; readonly status?: string; readonly error?: string; } function usage(): never { console.error( 'Usage: bun plugins/sp/scripts/inline-run-setup.ts --run-id --file [--spur-bin ]', ); console.error( ' bun plugins/sp/scripts/inline-run-setup.ts --fingerprint --task-file [--feature-file ] [--spur-bin ]', ); console.error( ' bun plugins/sp/scripts/inline-run-setup.ts --action --run-id --node --kind ' + '--status --ok --duration-ms [--spur-bin ]', ); console.error( ' bun plugins/sp/scripts/inline-run-setup.ts --close --run-id --status [--spur-bin ]', ); process.exit(2); } /** * The run id becomes a filename under `.spur/run/` (`-inline-setup.json`), so it must be a * single safe filename component before anything is written — the same guard class the * task-pipeline.yaml route-reason action applies to `$__runId` (task 0804 R8). The allowlist * refuses path separators, dot traversal (leading `.`), unresolved interpolation (`$`/`{`/`}`) and * every other shell/unspecified metachar; valid UUID/timestamp-slug ids pass. */ const SAFE_RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; function refuseUnsafeRunId(runId: string): never { // Refuse BEFORE any outcome write: an unsafe id must never reach // `.spur/run/-inline-setup.json` (no traversal, no unintended file). console.error(`inline-run-setup: refusing unsafe run id: ${runId}`); console.error( ' The run id must be a single safe filename component (alphanumeric/._-, no leading dot, ' + 'no path separators, interpolation or traversal; same class as the task-pipeline ' + 'route-reason guard, task 0804 R8). Allocate a fresh run id (uuid or timestamp slug) and retry.', ); process.exit(1); } /** * Resolve the spur repo checkout the same way the other prechecks resolve the CLI * (--spur-bin > SPUR_BIN > monorepo-local CLI entry > PATH `spur`), then derive the repo * root from the resolved main module. `bun /apps/cli/src/index.ts` → repo root is * three levels up. A bundle-only install (`spur` on PATH, a bundled `spur.js`, or a spur * binary without the app workspace) has no app entry to import — the caller fails that * closed with remediation guidance. */ function resolveAppEntry( spurBin: string, ): { entry: string; repoRoot: string } | { entry: null; repoRoot: null; chain: string } { let candidates: string[] = []; if (spurBin !== '') { candidates = [spurBin]; } else { // scripts/ -> plugins/sp/ -> /apps/cli/src/index.ts (fileURLToPath — raw // pathname breaks on %-encoded paths, e.g. spaces in the checkout directory). candidates = [fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url))]; } for (const candidate of candidates) { const tokens = candidate.split(/\s+/).filter(Boolean); // The main module is the last path-like token (tolerates `bun ` / // `bun run ` lead tokens). Only a TypeScript source entry proves a repo // checkout; a bundled `spur.js` or a bare `spur` binary does not. const mainModule = [...tokens].reverse().find((t) => t.endsWith('.ts')); if (mainModule === undefined || !existsSync(mainModule)) continue; // /apps/cli/src/index.ts → repo root; then require the app package source. const srcDir = dirname(mainModule); const repoRoot = resolve(srcDir, '..', '..', '..'); const appEntry = join(repoRoot, 'packages', 'app', 'src', 'index.ts'); if (existsSync(appEntry)) return { entry: appEntry, repoRoot }; return { entry: null, repoRoot: null, chain: `${candidate} (no ${appEntry})` }; } return { entry: null, repoRoot: null, chain: spurBin === '' ? 'PATH spur (bundle-only install)' : spurBin }; } function writeOutcome(runId: string, outcome: SetupOutcome): void { const runDir = join(process.cwd(), '.spur', 'run'); if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true }); writeFileSync(join(runDir, `${runId}-inline-setup.json`), `${JSON.stringify(outcome, null, 4)}\n`); } /** * `--fingerprint` mode (task 0862 R5): print the engine's proof-input digest for a task file * (plus optional feature file) instead of creating a run, so the inline driver can capture the * fresh digest bound `run.artifact` registration checks against. * * The digest itself comes from the exported app functions — never reimplemented here — and the * options mirror `ProofFingerprintActionRunner` (cwd, spec contents, fileSystem; no `--expect`). * Returns the process exit code: 0 printed, 1 read/resolve failure. */ async function printFingerprint(taskFile: string, featureFile: string, spurBin: string): Promise { const { entry, repoRoot, chain } = resolveAppEntry(spurBin); if (entry === null || repoRoot === null) { console.error( `inline-run-setup: FAIL — no monorepo checkout of spur is reachable via ${chain}. ` + 'The proof-input digest must be computed by the app service; a bundle-only install cannot do it.', ); return 1; } const app = (await import(entry)) as { computeProofInputFingerprint: (options: Record) => Promise; readProofInputContents: ( fileSystem: unknown, workdir: string, options: { taskFile?: unknown; featureFile?: unknown }, ) => Promise<{ ok: true; taskContent?: string; featureContent?: string } | { ok: false; error: string }>; }; const workdir = process.cwd(); // `undefined` fs takes readProofInputContents' node-filesystem default — the same default its // sibling createGitAlternateTree applies, and the same Node FS the CLI's runner injects. const inputs = await app.readProofInputContents(undefined, workdir, { taskFile, ...(featureFile.trim() !== '' ? { featureFile } : {}), }); if (!inputs.ok) { console.error(`inline-run-setup: FAIL — ${inputs.error}`); return 1; } const digest = await app.computeProofInputFingerprint({ cwd: workdir, ...(inputs.taskContent !== undefined ? { taskContent: inputs.taskContent } : {}), ...(inputs.featureContent !== undefined ? { featureContent: inputs.featureContent } : {}), }); process.stdout.write(`${digest}\n`); return 0; } /** Terminal statuses the inline driver may declare when closing its run row. */ const CLOSE_STATUSES = new Set(['done', 'failed', 'paused']); /** Finalize statuses — a finish emission is terminal, so only done|failed are valid (0868 #4). */ const ACTION_STATUSES = new Set(['done', 'failed']); /** Input for the ADR-117 emission modes (`--action` / `--close`). */ import type { WorkflowActionTraceWriter } from '@gobing-ai/app'; interface TraceModeInput { readonly runId: string; readonly close: boolean; readonly node: string; readonly kind: string; readonly status: string; readonly ok: boolean; readonly durationMs: number; readonly spurBin: string; } /** * Append one emission-failure line to `.spur/run/.log` — the run log the inline * driver already owns. Best-effort and synchronous (the process may exit immediately * after), and never throws: an unwritable log must not wedge the run (ADR-117 R3). */ function appendTraceFailureLine(runId: string, detail: string): void { try { const runDir = join(process.cwd(), '.spur', 'run'); if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true }); const safeRunId = runId.replace(/[^A-Za-z0-9._-]/g, '_'); const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); appendFileSync(join(runDir, `${safeRunId}.log`), `[${stamp}] ${detail}\n`); } catch { // Best-effort (R3): the run continues even when the failure cannot be recorded. } } /** * Emit one trace write through the SHARED `WorkflowActionTraceWriter` (task 0868 R5/R7). * `--action` is best-effort: an emission failure is recorded to the run log and reported * on stdout as `{"ok":false}`, and the script exits 0 so the run still reaches its declared * terminal state (R3/R12). `--close` is bookkeeping, so a missing run row or a persistence * failure fails loudly with exit 1 (review findings #1/#4). */ async function runTraceMode(input: TraceModeInput): Promise { const operation = input.close ? 'run.close' : 'action.finish'; const fail = (error: string): number => { appendTraceFailureLine( input.runId, `trace-emission-failed operation=${operation} run=${input.runId}` + `${input.node === '' ? '' : ` node=${input.node}`}${input.kind === '' ? '' : ` kind=${input.kind}`}: ${error}`, ); process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error })}\n`); // The action boundary is best-effort (exit 0); the run-row closure fails loudly (exit 1). return input.close ? 1 : 0; }; const { entry, repoRoot, chain } = resolveAppEntry(input.spurBin); if (entry === null || repoRoot === null) { return fail( `no monorepo checkout of spur is reachable via ${chain} — the shared trace writer ` + 'lives in packages/app (point SPUR_BIN at a repo checkout)', ); } // Compile-time link (0868 finding #5): the writer half is typed by the real packages/app // export (type-only import, erased at runtime) so a signature drift breaks THIS file's // typecheck instead of hiding behind the hand-declared cast. const app = (await import(entry)) as { openInlineRunProjectDb: (workdir: string) => Promise<{ adapter: unknown; close: () => void }>; createWorkflowActionTraceWriter: ( db: unknown, recordFailure?: (failure: unknown) => void, ) => WorkflowActionTraceWriter; }; let projectDb: { adapter: unknown; close: () => void } | undefined; try { projectDb = await app.openInlineRunProjectDb(process.cwd()); const writer = app.createWorkflowActionTraceWriter(projectDb.adapter, (failure: unknown) => { const detail = failure as { operation?: string; error?: string }; appendTraceFailureLine( input.runId, `trace-emission-failed operation=${detail.operation ?? operation} run=${input.runId}: ${detail.error ?? 'unknown error'}`, ); }); const result = ( input.close ? await writer.closeRun(input.runId, input.status) : await writer.recordAction({ runId: input.runId, node: input.node, kind: input.kind, status: input.status, ok: input.ok, durationMs: input.durationMs, }) ) as Record; if (result.ok !== true && result.failure !== undefined) { // One stdout shape for emission failures (0868 finding #1): flatten the guard's // nested failure object to the same `{ok, runId, error}` the direct paths emit. const failure = result.failure as { error?: string }; return fail(failure.error ?? 'unknown trace emission failure'); } process.stdout.write(`${JSON.stringify({ ...result, runId: input.runId })}\n`); return 0; } catch (error) { if (input.close && (error as { name?: string }).name === 'RunRowNotFoundError') { // The run row must exist before --close can mark it terminal (R6); a missing row // is a loud correctness failure, not a best-effort emission failure (finding #4). const message = error instanceof Error ? error.message : String(error); appendTraceFailureLine(input.runId, `trace-close-failed run=${input.runId}: ${message}`); process.stdout.write( `${JSON.stringify({ ok: false, runId: input.runId, error: message, code: 'RUN_NOT_FOUND' })}\n`, ); return 1; } return fail(error instanceof Error ? error.message : String(error)); } finally { projectDb?.close(); } } async function main(): Promise { let runId = ''; let file = ''; let fingerprint = false; let taskFile = ''; let featureFile = ''; let action = false; let close = false; let node = ''; let kind = ''; let status = ''; let okRaw = ''; let durationRaw = ''; let spurBin = getEnvVar('SPUR_BIN') ?? ''; const argv = process.argv.slice(2); for (let i = 0; i < argv.length; i++) { if (argv[i] === '--run-id') runId = argv[++i] ?? ''; else if (argv[i] === '--file') file = argv[++i] ?? ''; else if (argv[i] === '--fingerprint') fingerprint = true; else if (argv[i] === '--task-file') taskFile = argv[++i] ?? ''; else if (argv[i] === '--feature-file') featureFile = argv[++i] ?? ''; else if (argv[i] === '--action') action = true; else if (argv[i] === '--close') close = true; else if (argv[i] === '--node') node = argv[++i] ?? ''; else if (argv[i] === '--kind') kind = argv[++i] ?? ''; else if (argv[i] === '--status') status = argv[++i] ?? ''; else if (argv[i] === '--ok') okRaw = argv[++i] ?? ''; else if (argv[i] === '--duration-ms') durationRaw = argv[++i] ?? ''; else if (argv[i] === '--spur-bin') spurBin = argv[++i] ?? spurBin; } // Two mutually exclusive modes share this entry point: create/attach a run (run-id + file), or // print the proof digest for the inline driver (task 0862 R5). Mixing them is a usage error. if (fingerprint) { if (runId !== '' || file !== '' || taskFile.trim() === '') usage(); process.exit(await printFingerprint(taskFile, featureFile, spurBin)); } // ADR-117 emission modes (task 0868): the inline driver reports one completed action // boundary, or closes its run row at the declared terminal state. Both share the run-id // filename guard, the app-entry resolution chain and the best-effort failure contract. if (action || close) { if (action && close) usage(); if (runId.trim() === '' || status.trim() === '') usage(); if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId); if (close) { if (!CLOSE_STATUSES.has(status)) usage(); process.exit( await runTraceMode({ runId, close: true, node: '', kind: '', status, ok: true, durationMs: 0, spurBin, }), ); } if (node.trim() === '' || kind.trim() === '') usage(); if (!ACTION_STATUSES.has(status)) usage(); // `--ok` and `--duration-ms` are required and exact for the action mode // (review finding #2): a miscased `--ok True` or an omitted `--duration-ms` // must be a loud usage error, never a silently-defaulted `ok=0` / // `duration_ms=0` row. if (okRaw !== 'true' && okRaw !== 'false') usage(); const ok = okRaw === 'true'; const durationMs = Number(durationRaw); if (durationRaw.trim() === '' || !Number.isFinite(durationMs) || durationMs < 0) usage(); process.exit(await runTraceMode({ runId, close: false, node, kind, status, ok, durationMs, spurBin })); } if (runId.trim() === '' || file.trim() === '') usage(); if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId); const { entry, repoRoot, chain } = resolveAppEntry(spurBin); if (entry === null || repoRoot === null) { const outcome: SetupOutcome = { ok: false, runId, error: `inline run setup failed closed: no monorepo checkout of spur is reachable via ${chain}. ` + 'The authoritative run identity must be persisted by the app service ' + '(packages/app/src/services/inline-run-setup.ts); a bundle-only install cannot do this. ' + 'Remediation: point SPUR_BIN at a repo checkout, e.g. ' + 'SPUR_BIN="bun /path/to/spur/apps/cli/src/index.ts". The pipeline must not run unbound.', }; writeOutcome(runId, outcome); console.error(`inline-run-setup: FAIL for run ${runId}`); console.error(` ${outcome.error}`); process.exit(1); } // Dynamic import by absolute path: the app source graph resolves its own workspace // dependencies from the repo checkout, never from this plugin script's location. const app = (await import(entry)) as { createOrAttachInlineRun: (input: { workdir: string; getDb: () => Promise; file: string; runId: string; }) => Promise; openInlineRunProjectDb: (workdir: string) => Promise<{ adapter: unknown; close: () => void }>; }; const workdir = process.cwd(); const projectDb = await app.openInlineRunProjectDb(workdir); let exitCode = 0; try { const result = await app.createOrAttachInlineRun({ workdir, getDb: async () => projectDb.adapter, file, runId, }); writeOutcome(runId, result); if (!result.ok) { console.error(`inline-run-setup: FAIL for run ${runId}`); console.error(` ${result.error}`); exitCode = 1; } else { console.error( `inline-run-setup: ${result.attached ? 'attached' : 'created'} run ${runId} ` + `(${result.workflowName}, layer ${result.layer}, digest ${result.definitionDigest}, status ${result.status})`, ); } } finally { projectDb.close(); } process.exit(exitCode); } main().catch((e: unknown) => { console.error(`inline-run-setup: FAIL — ${e instanceof Error ? e.message : String(e)}`); process.exit(1); });