diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index a9b36b57..e9510e41 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -11,7 +11,7 @@ import { buildEngine } from '../engine/build-engine.js'; import type { GatePrompter } from '../gate/prompter.js'; import type { RunRenderer } from '../render/renderer.js'; import { captureIo } from '../test-support.js'; -import { driveRun, shouldBreakOnPause } from './drive.js'; +import { driveRun, isTerminalOutcome, shouldBreakOnPause } from './drive.js'; // gate → out: a single approval gate, then completes. The in-memory host pauses at the fail-closed gate. const GATED = `schema_version: '1.0' @@ -220,3 +220,13 @@ describe('shouldBreakOnPause', () => { expect(shouldBreakOnPause(event, true, new Set())).toBe(true); }); }); + +describe('isTerminalOutcome', () => { + it('is true for every terminal outcome, false for paused / undefined (the GC gate, 2.S/D-GC)', () => { + expect(isTerminalOutcome('completed')).toBe(true); + expect(isTerminalOutcome('failed')).toBe(true); + expect(isTerminalOutcome('cancelled')).toBe(true); + expect(isTerminalOutcome('paused')).toBe(false); // resumable — its media must survive + expect(isTerminalOutcome(undefined)).toBe(false); // an abnormal no-terminal unwind + }); +}); diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index ff2b21eb..1b07f425 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -1,11 +1,40 @@ -import { EngineStateError, type RunHandle, type WorkflowEngine } from '@relavium/core'; +import { + EngineStateError, + WorkflowValidationError, + validateWorkflowWithCatalog, + type RunHandle, + type WorkflowDefinition, + type WorkflowEngine, + type WorkflowModelCatalog, +} from '@relavium/core'; import type { HumanGatePausedEvent, RunEvent, RunPausedEvent } from '@relavium/shared'; import type { GatePrompter } from '../gate/prompter.js'; +import { CliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { RunRenderer } from '../render/renderer.js'; +/** + * The D15 catalog load-check, shared by `run` (a fresh load) and `gate` (a resume — re-validated against the + * CURRENT catalog so a model that lost a capability between the run and the resume is caught consistently, not + * only at the runtime FallbackChain pre-skip). An incapable / malformed-generative authored `output_modalities` + * surfaces as an `invalid_invocation` CliError (exit 2), like a parse fault; any other throw propagates. + */ +export function assertWorkflowCatalogValid( + workflow: WorkflowDefinition, + catalog: WorkflowModelCatalog, +): void { + try { + validateWorkflowWithCatalog(workflow, catalog); + } catch (err) { + if (err instanceof WorkflowValidationError) { + throw new CliError('invalid_invocation', err.message, { cause: err }); + } + throw err; + } +} + /** A run's terminal disposition (`undefined` means the stream ended with no terminal/paused — an abnormal unwind). */ export type RunOutcome = 'completed' | 'failed' | 'cancelled' | 'paused'; @@ -195,6 +224,18 @@ export function outcomeToExitCode(outcome: RunOutcome | undefined): ExitCode { } } +/** + * Did the run reach a TERMINAL disposition (`completed | failed | cancelled`)? `paused` is non-terminal (the run + * is resumable) and `undefined` is an abnormal no-terminal unwind — neither is terminal. The single owner of the + * "is this run done" predicate, shared by `run`/`gate` so the run-end host media GC fires only on a real terminal + * (2.S/D-GC — never while a run is merely paused, whose media it must keep for the resume). + */ +export function isTerminalOutcome( + outcome: RunOutcome | undefined, +): outcome is Exclude { + return outcome !== undefined && outcome !== 'paused'; +} + function nextOutcome(current: RunOutcome | undefined, event: RunEvent): RunOutcome | undefined { switch (event.type) { case 'run:completed': diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index ea173431..eef906dc 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1,4 +1,7 @@ import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { EngineStateError, @@ -9,6 +12,8 @@ import { } from '@relavium/core'; import { createClient, + createModelCatalogStore, + createProviderStore, createRunHistoryStore, runMigrations, type Db, @@ -18,13 +23,13 @@ import { import type { RunEvent } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildEngine } from '../engine/build-engine.js'; +import { buildEngine, type BuildEngineOptions } from '../engine/build-engine.js'; import { createCliHost } from '../engine/host.js'; import type { GatePrompter } from '../gate/prompter.js'; import { isCliError } from '../process/errors.js'; import { EXIT_CODES } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; -import { captureIo } from '../test-support.js'; +import { captureIo, CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; import { gateCommand, selectGate, type GateCommandDeps } from './gate.js'; /** A WorkflowEngine stub exposing only resumeFromCheckpoint — for the closed-handle / EngineStateError paths @@ -120,11 +125,39 @@ workflow: - { from: g, to: out } `; +// `os.homedir()` reads `HOME` on POSIX but `USERPROFILE` on Windows — override BOTH so the hermetic home holds +// cross-platform. The resume path builds the media wiring (the global CAS root resolves under the home), and the +// `save_to` scope root is the resumer's `cwd` — both must be tmpdirs, never the real home / repo cwd. +const HOME_ENV_VARS = ['HOME', 'USERPROFILE'] as const; +let root: string; +let home: string; +const savedHome = new Map(); +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relavium-gate-')); + home = mkdtempSync(join(tmpdir(), 'relavium-gate-home-')); + for (const v of HOME_ENV_VARS) { + savedHome.set(v, process.env[v]); + process.env[v] = home; + } +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + for (const v of HOME_ENV_VARS) { + const prior = savedHome.get(v); + if (prior === undefined) { + delete process.env[v]; + } else { + process.env[v] = prior; + } + } + rmSync(home, { recursive: true, force: true }); +}); + function globalOptions(): GlobalOptions { return { json: false, color: false, - cwd: process.cwd(), + cwd: root, configPath: undefined, verbosity: 'normal', }; @@ -184,6 +217,105 @@ describe('gateCommand', () => { return { runId, gateIds }; } + it('wires the same media host + catalog resolveMediaSurface on a gate-resumed run (2.S)', async () => { + // Seed a generative model into the SHARED db so the gate-path catalog (over opened.db) resolves it. + const dbDeps = { uuid: () => randomUUID(), now: () => Date.now() }; + const providerId = createProviderStore(db, dbDeps).upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }).id; + createModelCatalogStore(db, dbDeps).upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + const { runId } = await setupPausedRun(); + const { io } = captureIo(); + let captured: BuildEngineOptions | undefined; + let sweptArgs: { db: unknown; casRoot: string; currentRunId: string } | undefined; + const code = await gateCommand( + { runId, approve: true }, + { + ...deps(io), + // Capture what gate.ts assembled, then delegate to the real builder (same opts) so the text-only GATED + // resume completes — the media ports stay un-exercised (no media node), so no fs writes occur. + buildEngine: (opts) => { + captured = opts; + return buildEngine(opts); + }, + sweepMedia: (args) => { + sweptArgs = args; + return Promise.resolve(undefined); + }, + }, + ); + expect(code).toBe(EXIT_CODES.success); + // A gate-resumed run gets the same three media ports + the catalog routing as a fresh `run` — never + // silently text-only. + expect(captured?.host?.mediaStore).toBeDefined(); + expect(captured?.host?.mediaReferences).toBeDefined(); + expect(captured?.host?.mediaWrite).toBeDefined(); + expect(captured?.resolveMediaSurface?.('gpt-image-1')).toBe('generative'); + expect(captured?.resolveMediaSurface?.('unknown')).toBeUndefined(); + // ...and the gate-resume terminal runs the host media GC too (2.S/D-GC), over the same db, for this run. + expect(sweptArgs?.db).toBe(db); + expect(sweptArgs?.currentRunId).toBe(runId); + expect(sweptArgs?.casRoot.endsWith(join('.relavium', 'media'))).toBe(true); + }); + + it('re-runs the D15 catalog load-check on resume: a node incapable in the current catalog rejects (exit 2)', async () => { + // A workflow whose downstream agent (model `chat-text`) authored output_modalities [text, image] the model + // can't produce. The gate is BEFORE the agent, so the paused snapshot never ran it; on resume the gate path + // runs the SAME catalog check `run` does — and rejects (exit 2), consistently with a fresh run. + const incapableGated = `schema_version: '1.0' +workflow: + id: gate-incapable + agents: + - { id: painter, model: gpt-4o, provider: openai, system_prompt: paint } + nodes: + - { id: start, type: input } + - { id: g, type: human_gate, gate_type: approval } + - { id: a, type: agent, agent_ref: painter, model: chat-text, output_modalities: ['text', 'image'] } + - { id: out, type: output } + edges: + - { from: start, to: g } + - { from: g, to: a } + - { from: a, to: out } +`; + const dbDeps = { uuid: () => randomUUID(), now: () => Date.now() }; + const providerId = createProviderStore(db, dbDeps).upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }).id; + createModelCatalogStore(db, dbDeps).upsert({ + providerId, + modelId: 'chat-text', + displayName: 'Chat Text', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'chat', + capabilities: CHAT_TEXT_CAPABILITY_FLAGS, + }); + const { runId } = await setupPausedRun(incapableGated, {}); + const { io } = captureIo(); + let caught: unknown; + try { + await gateCommand({ runId, approve: true }, deps(io)); + } catch (err) { + caught = err; + } + expect(isCliError(caught)).toBe(true); + if (isCliError(caught)) { + expect(caught.code).toBe('invalid_invocation'); + expect(caught.message).toContain('chat-text'); // the catalog check rejected it, not a generic fault + } + }); + it('resumes a paused run on --approve, drives it to completion (exit 0), and persists the decision', async () => { const { runId } = await setupPausedRun(); const { io } = captureIo(); @@ -208,6 +340,16 @@ describe('gateCommand', () => { expect(doubled).toMatchObject({ output: { d: 14 } }); }); + it('swallows a throwing media GC on resume — a GC fault never fails the resume (2.S/D-GC, best-effort)', async () => { + const { runId } = await setupPausedRun(); + const { io } = captureIo(); + const code = await gateCommand( + { runId, approve: true }, + { ...deps(io), sweepMedia: () => Promise.reject(new Error('gc boom')) }, + ); + expect(code).toBe(EXIT_CODES.success); // the resume completed; the GC rejection was swallowed at the call site + }); + it('surfaces a corrupt stored inputs blob as a clean exit-2 fault (no silent empty-inputs resume)', async () => { const { runId } = await setupPausedRun(); // Corrupt the persisted input_json to a non-JSON blob (simulating a damaged store row). @@ -336,8 +478,24 @@ describe('gateCommand', () => { const { runId, gateIds } = await setupPausedRun(SEQ_GATES, {}); expect(gateIds).toHaveLength(1); // only g1 pends initially (sequential, not parallel) const { io } = captureIo(); - expect(await gateCommand({ runId, approve: true }, deps(io))).toBe(EXIT_CODES.gatePaused); // g1 → re-pause at g2 - expect(await gateCommand({ runId, approve: true }, deps(io))).toBe(EXIT_CODES.success); // blind repeat resolves g2 + // The GC is gated on a TERMINAL outcome (2.S/D-GC): a re-pause must NOT sweep (the still-paused run keeps its + // media); the second resolve completes → terminal → the GC runs. Pin BOTH directions. + let sweptOnRepause = false; + let sweptOnComplete = false; + expect( + await gateCommand( + { runId, approve: true }, + { ...deps(io), sweepMedia: () => ((sweptOnRepause = true), Promise.resolve(undefined)) }, + ), + ).toBe(EXIT_CODES.gatePaused); // g1 → re-pause at g2 + expect(sweptOnRepause).toBe(false); // the re-pause (non-terminal) skipped the GC + expect( + await gateCommand( + { runId, approve: true }, + { ...deps(io), sweepMedia: () => ((sweptOnComplete = true), Promise.resolve(undefined)) }, + ), + ).toBe(EXIT_CODES.success); // blind repeat resolves g2 → completes + expect(sweptOnComplete).toBe(true); // the terminal resume DID run the GC }); it('wires selectGatePrompter through to driveRun: a re-pause at a later gate is resolved inline (exit 0)', async () => { diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 312b8a45..c558a546 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -18,6 +18,8 @@ import { } from '../engine/build-engine.js'; import { createHistoryCheckpointer } from '../engine/checkpointer.js'; import { createCliHost } from '../engine/host.js'; +import { sweepHostMediaBestEffort as defaultSweepMedia } from '../engine/media-gc.js'; +import { buildMediaEngineWiring } from '../engine/media-wiring.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; import { decisionFromFlags, type GateFlags } from '../gate/decision.js'; import type { GatePrompter } from '../gate/prompter.js'; @@ -28,7 +30,12 @@ import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import type { RunRenderer } from '../render/renderer.js'; import { selectRenderer } from '../render/select.js'; -import { driveRun, outcomeToExitCode } from './drive.js'; +import { + assertWorkflowCatalogValid, + driveRun, + isTerminalOutcome, + outcomeToExitCode, +} from './drive.js'; export interface GateCommandArgs extends GateFlags { readonly runId: string; @@ -47,10 +54,36 @@ export interface GateCommandDeps { readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; readonly selectRenderer?: (io: CliIo, global: GlobalOptions) => RunRenderer; readonly selectGatePrompter?: (io: CliIo, global: GlobalOptions) => GatePrompter | undefined; + /** Injectable run-end host media GC (2.S/D-GC); defaults to {@link defaultSweepMedia}. Tests spy on it. */ + readonly sweepMedia?: typeof defaultSweepMedia; } const TERMINAL_STATUSES: ReadonlySet = new Set(['completed', 'failed', 'cancelled']); +/** + * Resume the run from its checkpoint, mapping a typed engine refusal (e.g. `workflow_mismatch` on a corrupt + * store) to a clean exit-2 invocation fault rather than an unhandled crash — surfaced with the engine's reason. + */ +async function resumeOrFail( + engine: WorkflowEngine, + params: Parameters[0], +): Promise { + try { + return await engine.resumeFromCheckpoint(params); + } catch (err) { + if (err instanceof EngineStateError) { + throw new CliError( + 'invalid_invocation', + `cannot resume run ${params.runId}: ${err.message}`, + { + cause: err, + }, + ); + } + throw err; + } +} + /** * The `relavium gate` core (**2.G**) — resolve a pending human gate from the terminal, the surface-agnostic * resume path for a `human_gate:paused` run (an interactive run that paused, or a CI run that exited `3`). It @@ -72,7 +105,7 @@ export async function gateCommand(args: GateCommandArgs, deps: GateCommandDeps): } const decision = flags.decision; - const { homeDir } = loadResolvedConfig({ + const { config, homeDir } = loadResolvedConfig({ cwd: deps.global.cwd, configPath: deps.global.configPath, }); @@ -141,33 +174,34 @@ export async function gateCommand(args: GateCommandArgs, deps: GateCommandDeps): } const providers = deps.providers ?? createProviderResolver(deps.io.env); + // Media host-wiring (2.S), the SAME helper `run` uses: a gate-resumed run that produces media must wire the + // same CAS + retention + catalog as the original run (else it would be silently text-only). The checkpointer + // stays. NOTE: `save_to`'s scope root is the RESUMER's cwd (`relavium gate` ran here), not the original run's + // cwd — so a run started in A, resumed from B, writes its save_to under B/.relavium/runs/. The authored + // `{{ run.id }}` segment still keeps writes per-run-disambiguated; persisting the original run's project + // root for an identical location is a deferred refinement (to be tracked in deferred-tasks.md). + const wiring = buildMediaEngineWiring(opened.db, homeDir, deps.global.cwd, config, (m) => + deps.io.writeErr(`${m}\n`), + ); + // D15 catalog load-check on the resume path too (the SAME helper `run` uses) — re-validate the snapshot's + // authored `output_modalities` against the CURRENT catalog, so a model that lost a capability between the + // original run and this resume is rejected consistently (exit 2), not silently routed at runtime. + assertWorkflowCatalogValid(workflow, wiring.workflowModelCatalog); const engine = await (deps.buildEngine ?? defaultBuildEngine)({ providers, - host: createCliHost(store, { checkpointer }), + host: createCliHost(store, { checkpointer, media: wiring.media }), + resolveMediaSurface: wiring.resolveMediaSurface, + ...(wiring.mediaCostEstimate === undefined + ? {} + : { mediaCostEstimate: wiring.mediaCostEstimate }), + }); + const handle = await resumeOrFail(engine, { + runId: args.runId, + workflow, + inputs, + gateId: selection.gateId, + decision, }); - let handle: RunHandle; - try { - handle = await engine.resumeFromCheckpoint({ - runId: args.runId, - workflow, - inputs, - gateId: selection.gateId, - decision, - }); - } catch (err) { - // A typed engine refusal (e.g. workflow_mismatch on a corrupt store) is an invalid invocation, not an - // unhandled crash — surface it cleanly as exit 2 with the engine's reason. - if (err instanceof EngineStateError) { - throw new CliError( - 'invalid_invocation', - `cannot resume run ${args.runId}: ${err.message}`, - { - cause: err, - }, - ); - } - throw err; - } const outcome = await driveRun({ engine, @@ -185,6 +219,22 @@ export async function gateCommand(args: GateCommandArgs, deps: GateCommandDeps): deps.io.writeOut(`run ${args.runId} already settled; nothing to resume\n`); return EXIT_CODES.success; } + + // Host media GC (2.S/D-GC, ADR-0042 §4) — only when the gate-resumed run reaches a TERMINAL event, exactly as + // `run` does (the same helper). A re-pause (a second gate / budget pause) is NOT terminal: skip it, so the + // still-paused run's media survives for the next resume. A GC failure is swallowed (never a correctness break). + if (wiring.media.casRoot !== undefined && isTerminalOutcome(outcome)) { + try { + await (deps.sweepMedia ?? defaultSweepMedia)({ + db: opened.db, + casRoot: wiring.media.casRoot, + currentRunId: args.runId, + }); + } catch { + // Defense-in-depth: the default sweeper already swallows, but the run-end GC must NEVER fail the resume — + // a throwing sweeper (a test, or a future impl) is swallowed here too (ADR-0042 §3). + } + } return outcomeToExitCode(outcome); } finally { opened.close(); diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index d27c89c2..ccff9031 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1,4 +1,5 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -8,10 +9,18 @@ import { type NodeExecutor, type NodeOutcome, } from '@relavium/core'; +import { + createClient, + createModelCatalogStore, + createProviderStore, + createRunHistoryStore, + runMigrations, + type Db, +} from '@relavium/db'; import { RunEventSchema } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { buildEngine } from '../engine/build-engine.js'; +import { buildEngine, type BuildEngineOptions } from '../engine/build-engine.js'; import { createProviderResolver } from '../engine/providers.js'; import type { GatePrompter } from '../gate/prompter.js'; import { isCliError } from '../process/errors.js'; @@ -19,7 +28,11 @@ import { EXIT_CODES } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import type { RunRenderer } from '../render/renderer.js'; -import { captureIo } from '../test-support.js'; +import { + CHAT_TEXT_CAPABILITY_FLAGS, + GENERATIVE_IMAGE_CAPABILITY_FLAGS, + captureIo, +} from '../test-support.js'; import { runCommand, type RunCommandDeps } from './run.js'; // A minimal real workflow: input → transform → output. Runs end-to-end through the standard node @@ -100,12 +113,77 @@ const AGENT_WF_GEMINI = AGENT_WF.replace('id: cli-run-agent', 'id: cli-run-agent 'model: gemini-2.5-flash, provider: gemini', ); +// An agent node whose authored output_modalities ([text, image]) exceed what the catalog model `chat-text` +// supports (text only) — parses fine, but the D15 catalog load-check (2.S Step 7) must reject it at LOAD via the +// chat inline-membership branch. The inline `model: chat-text` is the node-level override validate-catalog reads +// (node.model), distinct from the agent's base model. +const INCAPABLE_WF = `schema_version: '1.0' +workflow: + id: cli-run-incapable + agents: + - { id: writer, model: gpt-4o, provider: openai, system_prompt: write } + nodes: + - { id: start, type: input } + - { id: a, type: agent, agent_ref: writer, model: chat-text, prompt_template: 'go', output_modalities: ['text', 'image'] } + - { id: out, type: output } + edges: + - { from: start, to: a } + - { from: a, to: out } +`; + +// A node whose model `gen-image` is a media_surface 'generative' catalog row, with output_modalities that the +// generative branch rejects (it requires EXACTLY one media modality, no text) — exercises validate-catalog's +// generative branch through the real projection (which reads media.surface from the capabilities blob). +const GENERATIVE_REJECT_WF = `schema_version: '1.0' +workflow: + id: cli-run-gen-reject + agents: + - { id: painter, model: gpt-4o, provider: openai, system_prompt: paint } + nodes: + - { id: start, type: input } + - { id: a, type: agent, agent_ref: painter, model: gen-image, prompt_template: 'go', output_modalities: ['text', 'image'] } + - { id: out, type: output } + edges: + - { from: start, to: a } + - { from: a, to: out } +`; + +// Same shape but the node model is ABSENT from the catalog — the load-check must DEFER (not reject), so the run +// proceeds to build the engine. (output_modalities present so the check would evaluate the node if it resolved.) +const ABSENT_MODEL_WF = GENERATIVE_REJECT_WF.replace( + 'id: cli-run-gen-reject', + 'id: cli-run-absent', +).replace('model: gen-image', 'model: not-in-catalog'); + +// `os.homedir()` reads `HOME` on POSIX but `USERPROFILE` on Windows — override BOTH so the hermetic home holds +// cross-platform (the global CAS root resolves under it). Mirrors the generative e2e harness. +const HOME_ENV_VARS = ['HOME', 'USERPROFILE'] as const; + let root: string; +let home: string; +const savedHome = new Map(); beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'relavium-run-')); + // Point the home at a tmpdir so `os.homedir()` (→ `~/.relavium/media`, the CAS root) never resolves to the real + // developer home. The in-memory host path bypasses `put()` today, but keep the discipline so a future change + // exercising the CAS through the captured opts can't write to the real `~/.relavium`. + home = mkdtempSync(join(tmpdir(), 'relavium-run-home-')); + for (const v of HOME_ENV_VARS) { + savedHome.set(v, process.env[v]); + process.env[v] = home; + } }); afterEach(() => { rmSync(root, { recursive: true, force: true }); + for (const v of HOME_ENV_VARS) { + const prior = savedHome.get(v); + if (prior === undefined) { + delete process.env[v]; + } else { + process.env[v] = prior; + } + } + rmSync(home, { recursive: true, force: true }); }); function globalOptions(over: Partial = {}): GlobalOptions { @@ -134,6 +212,23 @@ function writeWorkflow(name: string, yaml: string): string { return path; } +/** An `openRunStore` backed by the given in-memory db — the durable-history stub the 2.S wiring tests share. */ +function historyOpenRunStore(db: Db): NonNullable { + return (workflow) => ({ + store: createRunHistoryStore(db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { + slug: workflow.workflow.id, + name: workflow.workflow.id, + definitionJson: JSON.stringify(workflow), + }, + }), + db, + close: () => {}, + }); +} + /** * A `buildEngine` whose `slow` node hangs until the run's AbortSignal fires (the engine's own * cancellation pattern). `reachedSlow` resolves the moment `slow` starts executing — by then run.ts @@ -184,6 +279,333 @@ describe('runCommand', () => { expect(out()).toContain('run completed'); }); + it('wires the media host + catalog resolveMediaSurface + the media ports when durable history is open (2.S)', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + const dbDeps = { uuid: () => randomUUID(), now: () => Date.now() }; + const providerId = createProviderStore(client.db, dbDeps).upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }).id; + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + let captured: BuildEngineOptions | undefined; + const { io } = captureIo(); + const path = writeWorkflow('happy.relavium.yaml', HAPPY); + try { + const code = await runCommand( + { workflow: path, input: ['n=3'] }, + deps(io, globalOptions(), { + // Durable history open ⇒ run.ts wires the media host + the catalog reader over this same db. + openRunStore: historyOpenRunStore(client.db), + // Capture what run.ts assembled, then run a real in-memory engine so the HAPPY workflow completes. + buildEngine: (opts) => { + captured = opts; + return buildEngine({ host: createInMemoryHost() }); + }, + }), + ); + expect(code).toBe(EXIT_CODES.success); + // The media host carries all three ports (CAS + retention + save_to), backed by the run-path roots + db. + expect(captured?.host?.mediaStore).toBeDefined(); + expect(captured?.host?.mediaReferences).toBeDefined(); + expect(captured?.host?.mediaWrite).toBeDefined(); + // resolveMediaSurface is the catalog projection over the SAME db: the seeded generative model routes; + // an unknown one is undefined (the host then defaults to the safe inline 'chat'). + expect(captured?.resolveMediaSurface?.('gpt-image-1')).toBe('generative'); + expect(captured?.resolveMediaSurface?.('unknown-model')).toBeUndefined(); + // With no project config, the call-site OMITS mediaCostEstimate (the exactOptionalPropertyTypes spread + // arm — never `{ mediaCostEstimate: undefined }`). + expect('mediaCostEstimate' in (captured ?? {})).toBe(false); + // The save_to scope root is the run's cwd: run.ts forwards `deps.global.cwd` into the helper, so a write + // through the wired port lands under /.relavium/runs (proves the cwd plumbing + the root value, not + // just that the port is defined). + const mediaWrite = captured?.host?.mediaWrite; + if (mediaWrite === undefined) { + throw new Error('expected run.ts to wire mediaWrite when durable history is open'); + } + await mediaWrite('out.bin', new Uint8Array([7])); + expect(existsSync(join(root, '.relavium', 'runs', 'out.bin'))).toBe(true); + } finally { + client.sqlite.close(); + } + }); + + it('threads [defaults].media_cost_estimate from config into the engine options (the populated arm)', async () => { + // A project config under the run cwd sets the per-modality estimate; run.ts must forward it to the builder. + mkdirSync(join(root, '.relavium'), { recursive: true }); + writeFileSync( + join(root, '.relavium', 'project.toml'), + '[defaults.media_cost_estimate]\nimage = 5\naudio = 9\n', + ); + const client = createClient(':memory:'); + runMigrations(client.db); + let captured: BuildEngineOptions | undefined; + const { io } = captureIo(); + const path = writeWorkflow('happy.relavium.yaml', HAPPY); + try { + const code = await runCommand( + { workflow: path, input: ['n=3'] }, + deps(io, globalOptions(), { + openRunStore: historyOpenRunStore(client.db), + buildEngine: (opts) => { + captured = opts; + return buildEngine({ host: createInMemoryHost() }); + }, + }), + ); + expect(code).toBe(EXIT_CODES.success); + expect(captured?.mediaCostEstimate).toEqual({ image: 5, audio: 9 }); + } finally { + client.sqlite.close(); + } + }); + + it('runs the host media GC at run-end with the durable db + CAS root + run id (2.S/D-GC)', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + const { io } = captureIo(); + const path = writeWorkflow('happy.relavium.yaml', HAPPY); + let swept: { db: unknown; casRoot: string; currentRunId: string } | undefined; + try { + const code = await runCommand( + { workflow: path, input: ['n=3'] }, + deps(io, globalOptions(), { + openRunStore: historyOpenRunStore(client.db), + sweepMedia: (args) => { + swept = args; + return Promise.resolve(undefined); + }, + }), + ); + expect(code).toBe(EXIT_CODES.success); + // The GC ran once at the terminal, over the SAME durable connection + the global CAS root, for this run. + expect(swept?.db).toBe(client.db); + expect(swept?.casRoot.endsWith(join('.relavium', 'media'))).toBe(true); + expect(typeof swept?.currentRunId).toBe('string'); + expect(swept?.currentRunId).not.toBe(''); + } finally { + client.sqlite.close(); + } + }); + + it('skips the host media GC when durable history is closed (no references db to GC over)', async () => { + const { io } = captureIo(); + const path = writeWorkflow('happy.relavium.yaml', HAPPY); + let swept = false; + const code = await runCommand( + { workflow: path, input: ['n=3'] }, + deps(io, globalOptions(), { + sweepMedia: () => { + swept = true; + return Promise.resolve(undefined); + }, + }), + ); + expect(code).toBe(EXIT_CODES.success); + expect(swept).toBe(false); // no openRunStore ⇒ no media wiring ⇒ the GC never runs + }); + + it('skips the host media GC on a non-terminal (paused) outcome — a resumable run keeps its media (2.S/D-GC)', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + const { io } = captureIo(); + const path = writeWorkflow('gated.relavium.yaml', GATED); + let swept = false; + try { + const code = await runCommand( + { workflow: path, input: [] }, + deps(io, globalOptions(), { + openRunStore: historyOpenRunStore(client.db), + sweepMedia: () => { + swept = true; + return Promise.resolve(undefined); + }, + }), + ); + expect(code).toBe(EXIT_CODES.gatePaused); // exit 3 — the run paused at the human gate + } finally { + client.sqlite.close(); + } + expect(swept).toBe(false); // the GC must NOT run while the run is merely paused (its media survives the resume) + }); + + it('swallows a throwing media GC at run-end — a GC fault never fails the run (2.S/D-GC, best-effort)', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + const { io } = captureIo(); + const path = writeWorkflow('happy.relavium.yaml', HAPPY); + try { + const code = await runCommand( + { workflow: path, input: ['n=3'] }, + deps(io, globalOptions(), { + openRunStore: historyOpenRunStore(client.db), + sweepMedia: () => Promise.reject(new Error('gc boom')), + }), + ); + expect(code).toBe(EXIT_CODES.success); // the run completed; the GC rejection was swallowed at the call site + } finally { + client.sqlite.close(); + } + }); + + it('rejects an agent node whose output_modalities exceed the catalog model at LOAD — exit 2, engine never built (D15)', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + const dbDeps = { uuid: () => randomUUID(), now: () => Date.now() }; + const providerId = createProviderStore(client.db, dbDeps).upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }).id; + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'chat-text', + displayName: 'Chat Text', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'chat', + capabilities: CHAT_TEXT_CAPABILITY_FLAGS, + }); + const { io } = captureIo(); + const path = writeWorkflow('incapable.relavium.yaml', INCAPABLE_WF); + let caught: unknown; + try { + await runCommand( + { workflow: path, input: [] }, + deps(io, globalOptions(), { + // Key present ⇒ the pre-flight passes; the D15 load-check is what must reject the run. + providers: createProviderResolver({ RELAVIUM_OPENAI_API_KEY: 'sk-test' }), + openRunStore: historyOpenRunStore(client.db), + // The load-check runs BEFORE the engine builds — a regression that skipped it would trip this. + buildEngine: () => { + throw new Error('the engine must not build — the D15 load-check rejects first'); + }, + }), + ); + } catch (err) { + caught = err; + } finally { + client.sqlite.close(); + } + expect(isCliError(caught)).toBe(true); + if (isCliError(caught)) { + expect(caught.code).toBe('invalid_invocation'); + expect(caught.message).toContain('chat-text'); // the message names the offending model, secret-free + } + }); + + it('rejects a generative-surface model with a malformed output_modalities at LOAD — exit 2 (D15 generative branch)', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + const dbDeps = { uuid: () => randomUUID(), now: () => Date.now() }; + const providerId = createProviderStore(client.db, dbDeps).upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }).id; + // A generative catalog row: the projection reads media.surface='generative' from THIS capabilities blob, so + // the load-check takes the generative branch (exactly one media modality, no text) and rejects [text, image]. + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'gen-image', + displayName: 'Gen Image', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + capabilities: GENERATIVE_IMAGE_CAPABILITY_FLAGS, + }); + const { io } = captureIo(); + const path = writeWorkflow('gen-reject.relavium.yaml', GENERATIVE_REJECT_WF); + let caught: unknown; + try { + await runCommand( + { workflow: path, input: [] }, + deps(io, globalOptions(), { + providers: createProviderResolver({ RELAVIUM_OPENAI_API_KEY: 'sk-test' }), + openRunStore: historyOpenRunStore(client.db), + buildEngine: () => { + throw new Error('the engine must not build — the generative load-check rejects first'); + }, + }), + ); + } catch (err) { + caught = err; + } finally { + client.sqlite.close(); + } + expect(isCliError(caught)).toBe(true); + if (isCliError(caught)) { + expect(caught.code).toBe('invalid_invocation'); + expect(caught.message).toContain('generative'); // distinguishes the generative branch from chat-membership + // ...and pins the field-named, modality-listing contract (validate-catalog's WorkflowValidationError), not + // just the branch keyword — a message that dropped the node field/modality detail would fail here. + expect(caught.message).toContain('output_modalities'); + expect(caught.message).toContain('image'); + } + }); + + it('DEFERS (does not reject) a node whose model is absent from the catalog — the engine still builds (D15)', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + const { io } = captureIo(); + const path = writeWorkflow('absent.relavium.yaml', ABSENT_MODEL_WF); + let built = false; + let caught: unknown; + try { + await runCommand( + { workflow: path, input: [] }, + deps(io, globalOptions(), { + providers: createProviderResolver({ RELAVIUM_OPENAI_API_KEY: 'sk-test' }), + openRunStore: historyOpenRunStore(client.db), // catalog open, but `not-in-catalog` is unseeded + // Halt right after the load-check so we observe the defer without running the agent over the network. + buildEngine: () => { + built = true; + throw new Error('halt after the load-check deferred'); + }, + }), + ); + } catch (err) { + caught = err; + } finally { + client.sqlite.close(); + } + expect(built).toBe(true); // the load-check reached buildEngine — it DEFERRED the unresolvable model + expect(isCliError(caught)).toBe(false); // ...and did NOT reject with an invocation fault + }); + + it('skips the load-check entirely when durable history is closed (no catalog to check against)', async () => { + // No `openRunStore` ⇒ `opened` is undefined ⇒ the whole media-wiring + load-check block is skipped. Even the + // would-be-incapable INCAPABLE_WF then builds the engine (there is no catalog), pinning the guard boundary. + const { io } = captureIo(); + const path = writeWorkflow('incapable.relavium.yaml', INCAPABLE_WF); + let built = false; + let caught: unknown; + try { + await runCommand( + { workflow: path, input: [] }, + deps(io, globalOptions(), { + providers: createProviderResolver({ RELAVIUM_OPENAI_API_KEY: 'sk-test' }), + buildEngine: () => { + built = true; + throw new Error('halt after the skipped load-check'); + }, + }), + ); + } catch (err) { + caught = err; + } + expect(built).toBe(true); // no durable history ⇒ no catalog ⇒ the load-check never runs, never over-rejects + expect(isCliError(caught)).toBe(false); + }); + it('awaits the renderer finalize() once after the run loop (the TUI teardown wire)', async () => { const path = writeWorkflow('happy.relavium.yaml', HAPPY); const { io } = captureIo(); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 44a6ac41..8e408662 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -13,6 +13,8 @@ import { type BuildEngineOptions, } from '../engine/build-engine.js'; import { createCliHost } from '../engine/host.js'; +import { sweepHostMediaBestEffort as defaultSweepMedia } from '../engine/media-gc.js'; +import { buildMediaEngineWiring } from '../engine/media-wiring.js'; import { createProviderResolver, neededProviderIds, @@ -28,7 +30,12 @@ import type { GlobalOptions } from '../process/options.js'; import type { RunRenderer } from '../render/renderer.js'; import { selectRenderer } from '../render/select.js'; import { resolveWorkflowSource } from '../workflows/resolve.js'; -import { driveRun, outcomeToExitCode } from './drive.js'; +import { + assertWorkflowCatalogValid, + driveRun, + isTerminalOutcome, + outcomeToExitCode, +} from './drive.js'; import { parseInputArgs, resolveInputs } from './inputs.js'; export interface RunCommandArgs { @@ -59,6 +66,11 @@ export interface RunCommandDeps { * without a TTY, or omit it so a gate pause exits 3 like the non-interactive path. */ readonly selectGatePrompter?: (io: CliIo, global: GlobalOptions) => GatePrompter | undefined; + /** + * Injectable run-end host media GC (2.S/D-GC); defaults to {@link defaultSweepMedia}. Tests spy on it to + * assert the run-end invocation without touching a real CAS, and the in-memory unit path never reaches it. + */ + readonly sweepMedia?: typeof defaultSweepMedia; } /** @@ -77,7 +89,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // Config (2.B) — a malformed layer surfaces as exit 2; the project dir powers id/slug discovery, // homeDir locates `~/.relavium/history.db` (2.H). - const { projectConfigDir, homeDir } = loadResolvedConfig({ + const { config, projectConfigDir, homeDir } = loadResolvedConfig({ cwd: deps.global.cwd, configPath: deps.global.configPath, }); @@ -124,9 +136,32 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr ); } try { - const engine = await build( - opened === undefined ? { providers } : { providers, host: createCliHost(opened.store) }, - ); + // Media host-wiring (2.S): when durable history is open, the SAME `~/.relavium/history.db` connection + // backs the `model_catalog` reader (→ `resolveMediaSurface` routing) + the `media_references` retention + // junction, and the host gets the global CAS root (`~/.relavium/media/`) + the project-relative `save_to` + // root (`.relavium/runs/`). Absent (the in-memory unit/harness path) ⇒ no media ports, so a media-producing + // run fails loud — never a silent leak. The per-modality `media_cost_estimate` default folds in from config. + let engineOptions: BuildEngineOptions = { providers }; + let mediaCasRoot: string | undefined; + if (opened !== undefined) { + const wiring = buildMediaEngineWiring(opened.db, homeDir, deps.global.cwd, config, (m) => + deps.io.writeErr(`${m}\n`), + ); + mediaCasRoot = wiring.media.casRoot; // hoisted for the run-end host media GC below + // D15 load-check (ADR-0044 §2 / ADR-0045 §1): an incapable / malformed-generative authored `output_modalities` + // fails fast at LOAD (exit 2), not only at the runtime FallbackChain pre-skip. `gate` runs the SAME check + // (drive.ts), so a fresh run and a resume reject consistently. + assertWorkflowCatalogValid(def, wiring.workflowModelCatalog); + engineOptions = { + providers, + host: createCliHost(opened.store, { media: wiring.media }), + resolveMediaSurface: wiring.resolveMediaSurface, + ...(wiring.mediaCostEstimate === undefined + ? {} + : { mediaCostEstimate: wiring.mediaCostEstimate }), + }; + } + const engine = await build(engineOptions); const handle = engine.start({ workflow: def, inputs }); // Hand the live run to the shared driver (2.G): it owns the event loop, the SIGINT cooperative-cancel @@ -142,6 +177,23 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr io: deps.io, }); + // Host media GC (2.S/D-GC, ADR-0042 §4) — a best-effort pass keyed on the run reaching a TERMINAL event: the + // clean-terminal reclaim retry (a crash-dropped prior sweep) + the grace-window byte reclaim + the CAS-orphan + // sweep, over the same durable `history.db`. Swallows any throw (never a run-correctness break). Skipped on a + // `paused` outcome (the run is resumable — its media must survive) and on the in-memory unit/harness path. + if (opened !== undefined && mediaCasRoot !== undefined && isTerminalOutcome(outcome)) { + try { + await (deps.sweepMedia ?? defaultSweepMedia)({ + db: opened.db, + casRoot: mediaCasRoot, + currentRunId: handle.runId, + }); + } catch { + // Defense-in-depth: the default sweeper already swallows, but the run-end GC must NEVER fail the run — + // a throwing sweeper (a test, or a future impl) is swallowed here too (ADR-0042 §3). + } + } + return outcomeToExitCode(outcome); } finally { opened?.close(); diff --git a/apps/cli/src/config/resolve.test.ts b/apps/cli/src/config/resolve.test.ts index ff9898a1..b8b922dc 100644 --- a/apps/cli/src/config/resolve.test.ts +++ b/apps/cli/src/config/resolve.test.ts @@ -10,11 +10,21 @@ describe('resolveConfig', () => { defaultModel: undefined, fsScope: undefined, maxTokensEstimate: undefined, + mediaCostEstimate: undefined, variables: {}, mcpServers: [], }); }); + it('takes media_cost_estimate (2.S/D17) from the highest layer present — whole-object, not per-key merge', () => { + const workspace: ProjectConfig = { defaults: { media_cost_estimate: { image: 2, audio: 5 } } }; + const project: ProjectConfig = { defaults: { media_cost_estimate: { image: 9 } } }; + // project replaces workspace (last-writer-wins like the other defaults), it does not merge audio in. + expect(resolveConfig({ workspace, project }).mediaCostEstimate).toEqual({ image: 9 }); + expect(resolveConfig({ workspace }).mediaCostEstimate).toEqual({ image: 2, audio: 5 }); + expect(resolveConfig({}).mediaCostEstimate).toBeUndefined(); + }); + it('applies last-writer-wins precedence (project > workspace > global) for the default model', () => { const global: GlobalConfig = { preferences: { default_model: 'g' } }; const workspace: ProjectConfig = { defaults: { model: 'w' } }; diff --git a/apps/cli/src/config/resolve.ts b/apps/cli/src/config/resolve.ts index 7b3ef714..71ffba5b 100644 --- a/apps/cli/src/config/resolve.ts +++ b/apps/cli/src/config/resolve.ts @@ -17,6 +17,10 @@ export interface ResolvedConfig { readonly defaultModel: string | undefined; readonly fsScope: FsScope; readonly maxTokensEstimate: number | undefined; + /** `[defaults].media_cost_estimate` (2.S/D17, ADR-0044 §3) — per-modality output unit-count defaults for the + * pre-egress media-cost governor. Resolved last-writer-wins like the other defaults; absent ⇒ the engine's + * built-in unit estimate. (The per-unit price lives in the model catalog, never here.) */ + readonly mediaCostEstimate: ProjectDefaults['media_cost_estimate']; readonly variables: Readonly>; readonly mcpServers: readonly McpServerRegistration[]; } @@ -36,6 +40,8 @@ export function resolveConfig(layers: ConfigLayers): ResolvedConfig { fsScope: project?.defaults?.fs_scope ?? workspace?.defaults?.fs_scope, maxTokensEstimate: project?.defaults?.max_tokens_estimate ?? workspace?.defaults?.max_tokens_estimate, + mediaCostEstimate: + project?.defaults?.media_cost_estimate ?? workspace?.defaults?.media_cost_estimate, variables: { ...workspace?.variables, ...project?.variables }, mcpServers: mergeMcpServers(global?.mcp_servers, workspace?.mcp_servers, project?.mcp_servers), }; diff --git a/apps/cli/src/engine/build-engine.test.ts b/apps/cli/src/engine/build-engine.test.ts new file mode 100644 index 00000000..fe16a503 --- /dev/null +++ b/apps/cli/src/engine/build-engine.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createClient, runMigrations } from '@relavium/db'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildEngine } from './build-engine.js'; +import { createCliHost } from './host.js'; + +/** + * Wiring-level coverage for the 2.S media deps `buildEngine` threads into `AgentRunnerDeps` + * (`resolveMediaSurface` / `resolveForEgress` / `mediaCostEstimate`). The DEEP generative routing + * behavior — a `'generative'` surface routing an agent node to `generateMedia`, the de-inline to a + * handle, and the per-modality cost addend — is exercised at the engine level in + * `packages/core/src/engine/agent-runner.test.ts`; the full `relavium run` end-to-end is the 2.S + * acceptance fixture (the run-path caller wiring). Here we assert the assembler accepts + binds the deps. + */ +describe('buildEngine media wiring (2.S)', () => { + const cleanups: Array<() => void> = []; + afterEach(() => { + for (const c of cleanups.splice(0)) c(); + }); + + it('binds the media deps when a media host + routing/cost options are given (resolveForEgress ← host CAS)', async () => { + const casRoot = mkdtempSync(join(tmpdir(), 'relavium-cas-')); + const client = createClient(':memory:'); + cleanups.push( + () => client.sqlite.close(), + () => rmSync(casRoot, { recursive: true, force: true }), + ); + runMigrations(client.db); + const host = createCliHost(undefined, { media: { casRoot, referenceDb: client.db } }); + // The media deps thread through without error; `resolveForEgress` binds to the single `host.mediaStore` + // CAS (one store backs both the de-inline and the failover re-materialization, ADR-0042). + const engine = await buildEngine({ + host, + resolveMediaSurface: () => 'generative', + mediaCostEstimate: { image: 1 }, + }); + expect(engine).toBeDefined(); + }); + + it('builds a text-only engine when no media options/host are given (the deps stay absent, no throw)', async () => { + const engine = await buildEngine(); + expect(engine).toBeDefined(); + }); +}); diff --git a/apps/cli/src/engine/build-engine.ts b/apps/cli/src/engine/build-engine.ts index aad18ad0..bdc04c8d 100644 --- a/apps/cli/src/engine/build-engine.ts +++ b/apps/cli/src/engine/build-engine.ts @@ -7,6 +7,7 @@ import { type AgentRunnerDeps, type ExecutionHost, } from '@relavium/core'; +import type { MediaCostEstimate, MediaSurface } from '@relavium/shared'; import { createCliHost } from './host.js'; import { createProviderResolver, type ProviderResolver } from './providers.js'; @@ -16,6 +17,18 @@ export interface BuildEngineOptions { readonly host?: ExecutionHost; /** Override the provider seam (tests inject a stub provider + dummy key). */ readonly providers?: ProviderResolver; + /** + * The model → `media_surface` routing projection (2.S, ADR-0045 §1) the caller builds from the DB + * `model_catalog` (`createModelCatalogStore(...).resolveMediaSurface`). Absent / `undefined` ⇒ every model + * routes inline (`'chat'`), so no generative-surface model is reachable. + */ + readonly resolveMediaSurface?: (model: string) => MediaSurface | undefined; + /** + * The `[defaults].media_cost_estimate` per-modality unit-count defaults (2.S/D17, ADR-0044 §3) the caller + * resolves from config. Threads into the pre-egress media-cost governor; absent ⇒ the built-in default + * unit estimate is used. Media still folds at 0 until a verified catalog rate lands (never fabricated). + */ + readonly mediaCostEstimate?: MediaCostEstimate; } /** @@ -35,6 +48,11 @@ export async function buildEngine(options: BuildEngineOptions = {}): Promise new Promise((resolveSleep) => setTimeout(resolveSleep, ms)), now: () => Date.now(), + // The media routing/cost/egress deps (2.S) — each present only when its source is wired (`undefined` is + // OMITTED, not assigned — the fields are `?:`, exactOptionalPropertyTypes). resolveMediaSurface routes a + // generative model to generateMedia (ADR-0045 §1); resolveForEgress re-materializes a handle on failover + // (D8, ADR-0043); mediaCostEstimate threads the per-modality unit defaults into the pre-egress governor (D17). + ...(options.resolveMediaSurface === undefined + ? {} + : { resolveMediaSurface: options.resolveMediaSurface }), + ...(mediaStore === undefined + ? {} + : { resolveForEgress: (handle, provider) => mediaStore.resolveForEgress(handle, provider) }), + ...(options.mediaCostEstimate === undefined + ? {} + : { mediaCostEstimate: options.mediaCostEstimate }), }; return new WorkflowEngine({ diff --git a/apps/cli/src/engine/host.test.ts b/apps/cli/src/engine/host.test.ts index 5bf89a60..2acfb4dd 100644 --- a/apps/cli/src/engine/host.test.ts +++ b/apps/cli/src/engine/host.test.ts @@ -1,7 +1,12 @@ -import type { Checkpointer, RunStore } from '@relavium/core'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { Checkpointer, ExecutionHost, RunStore } from '@relavium/core'; +import { createClient, createMediaReferenceStore, runMigrations } from '@relavium/db'; import { describe, expect, it } from 'vitest'; -import { createCliHost } from './host.js'; +import { createCliHost, type CliMediaOptions } from './host.js'; /** A stand-in DURABLE store (not the in-memory reference) — what the gate path injects alongside a checkpointer. */ const durableStore: RunStore = { @@ -48,4 +53,183 @@ describe('createCliHost', () => { expect(typeof cancel).toBe('function'); cancel(); // clears the timer — no dangling handle }); + + describe('fetchMedia (the SSRF media-egress port, 2.S / ADR-0043)', () => { + // Asserts the port is wired AND returns it narrowed (no non-null `!`); a missing port fails loudly here. + function mediaFetch(): NonNullable { + const { fetchMedia } = createCliHost(); + if (fetchMedia === undefined) { + throw new Error('createCliHost must wire the fetchMedia media-egress port'); + } + return fetchMedia; + } + + // All three reject BEFORE any network: scheme/credential checks and the literal-IP range block run ahead of + // DNS/connect, so the wiring is verified offline (the mechanism's own redirect/rebinding vectors are covered + // by @relavium/db's 23 media-egress tests — here we only assert the CLI wires it with allowPrivate=false). + it('rejects a non-HTTPS url (insecure_url), opening no connection', async () => { + await expect(mediaFetch()('http://media.example/a.png', 1000)).rejects.toMatchObject({ + code: 'insecure_url', + }); + }); + + it('rejects a url with embedded credentials (insecure_url)', async () => { + await expect( + mediaFetch()('https://user:pass@media.example/a.png', 1000), + ).rejects.toMatchObject({ + code: 'insecure_url', + }); + }); + + it('rejects a literal private/loopback target — proving allowPrivate=false (blocked_host, no network)', async () => { + await expect(mediaFetch()('https://127.0.0.1/a.png', 1000)).rejects.toMatchObject({ + code: 'blocked_host', + }); + }); + }); + + describe('mediaWrite (the save_to write port, 2.S / ADR-0044 §2)', () => { + it('is unwired when no saveToRoot is given (a save_to then fails the run loud)', () => { + expect(createCliHost().mediaWrite).toBeUndefined(); + }); + + it('writes jailed under the saveToRoot and rejects a traversal escape', async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-saveto-')); + try { + const { mediaWrite } = createCliHost(undefined, { media: { saveToRoot: root } }); + if (mediaWrite === undefined) { + throw new Error('createCliHost must wire mediaWrite when a saveToRoot is given'); + } + await mediaWrite('sub/out.bin', new Uint8Array([1, 2, 3])); + expect(Array.from(readFileSync(join(root, 'sub', 'out.bin')))).toEqual([1, 2, 3]); + // The wired port rejects a `..` traversal via its lexical relative-path guard — assert the CAUSE (not + // just "an error") so a wiring bug that rejected for the wrong reason wouldn't pass, and confirm nothing + // was written above the root. (The deeper realpath+commonpath symlink jail is covered by @relavium/db's + // media-write tests; here we only verify the CLI wired the port under the right scope root.) + await expect(mediaWrite('../escape.bin', new Uint8Array([9]))).rejects.toThrow( + /must not contain a "\.\." segment/, + ); + expect(existsSync(join(root, '..', 'escape.bin'))).toBe(false); + // An ABSOLUTE path is rejected for its own distinct cause (not the `..` one) — confirm the wired port + // forwards every relative-only rule, not just traversal. (The full drive/UNC/symlink matrix is the db + // media-write suite's; here we assert the CLI didn't narrow the guard to `..` alone.) + await expect(mediaWrite('/etc/escape.bin', new Uint8Array([9]))).rejects.toThrow( + /must be relative/, + ); + // A PRE-ABORTED signal short-circuits the write cooperatively (the port's throwIfAborted) — the bytes + // never land. (wireSaveToPort's mkdir runs first, so the scope root may exist, but no file is written.) + await expect( + mediaWrite('aborted.bin', new Uint8Array([9]), AbortSignal.abort()), + ).rejects.toThrow(/was aborted/); + expect(existsSync(join(root, 'aborted.bin'))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('provisions a not-yet-existing saveToRoot LAZILY on the first write (not at host construction)', async () => { + // The write port fail-closes if its jail root is missing — a fresh project has no `.relavium/runs/` yet, so + // the host `mkdir -p`s it. But LAZILY (on the first write), so wiring the host for a run WITHOUT save_to + // never needs cwd write access (read-only-env safe). Point at a nested root whose ancestors do NOT exist. + const base = mkdtempSync(join(tmpdir(), 'relavium-saveto-fresh-')); + const saveToRoot = join(base, '.relavium', 'runs'); + try { + expect(existsSync(saveToRoot)).toBe(false); + const { mediaWrite } = createCliHost(undefined, { media: { saveToRoot } }); + if (mediaWrite === undefined) { + throw new Error('createCliHost must wire mediaWrite when a saveToRoot is given'); + } + expect(existsSync(saveToRoot)).toBe(false); // NOT provisioned at construction — lazy + await mediaWrite('out.bin', new Uint8Array([7])); + expect(existsSync(saveToRoot)).toBe(true); // provisioned on the first write, and the bytes landed + expect(Array.from(readFileSync(join(saveToRoot, 'out.bin')))).toEqual([7]); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + }); + + describe('mediaStore + mediaReferences (the CAS + retention ports, 2.S / ADR-0042)', () => { + it('are unwired without their config (a media-producing run then fails loud)', () => { + const host = createCliHost(); + expect(host.mediaStore).toBeUndefined(); + expect(host.mediaReferences).toBeUndefined(); + }); + + it('wires each of the three media ports independently from its own config field', () => { + const casRoot = mkdtempSync(join(tmpdir(), 'relavium-cas-')); + const saveToRoot = mkdtempSync(join(tmpdir(), 'relavium-saveto-')); + const client = createClient(':memory:'); + // [mediaStore, mediaReferences, mediaWrite] presence for a given media config. + const wired = (media: CliMediaOptions): boolean[] => { + const host = createCliHost(undefined, { media }); + return [host.mediaStore, host.mediaReferences, host.mediaWrite].map(Boolean); + }; + try { + runMigrations(client.db); + // Each single field wires ONLY its own port (a coupling regression — gating one port on another's + // field — fails here): mediaStore ⇐ casRoot, mediaReferences ⇐ referenceDb, mediaWrite ⇐ saveToRoot. + expect(wired({ casRoot })).toEqual([true, false, false]); + expect(wired({ referenceDb: client.db })).toEqual([false, true, false]); + expect(wired({ saveToRoot })).toEqual([false, false, true]); + // The realistic run-path config — all three fields — exposes all three ports. + expect(wired({ casRoot, referenceDb: client.db, saveToRoot })).toEqual([true, true, true]); + } finally { + client.sqlite.close(); + rmSync(casRoot, { recursive: true, force: true }); + rmSync(saveToRoot, { recursive: true, force: true }); + } + }); + + it('wires a single content-addressed mediaStore (round-trip + fail-closed on an unknown handle)', async () => { + const casRoot = mkdtempSync(join(tmpdir(), 'relavium-cas-')); + try { + const store = createCliHost(undefined, { media: { casRoot } }).mediaStore; + if (store === undefined) { + throw new Error('createCliHost must wire mediaStore when a casRoot is given'); + } + // A content-addressed round-trip proves it is a real FilesystemMediaStore over the CAS root. + const handle = await store.put(new Uint8Array([1, 2, 3, 4]), 'application/octet-stream'); + expect(handle).toMatch(/^media:\/\/sha256-[0-9a-f]{64}$/); + expect(Array.from(await store.get(handle))).toEqual([1, 2, 3, 4]); + // Fail-closed: an unknown handle (no CAS file) rejects with the file-not-found CAUSE (a content-address + // miss), never serving stray bytes — the CAUSE is asserted, mirroring the mediaWrite traversal rigor. + await expect(store.get(`media://sha256-${'0'.repeat(64)}`)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + rmSync(casRoot, { recursive: true, force: true }); + } + }); + + it('wires a mediaReferences port whose record + reclaim are both backed by the passed referenceDb', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const refs = createCliHost(undefined, { + media: { referenceDb: client.db }, + }).mediaReferences; + if (refs === undefined) { + throw new Error('createCliHost must wire mediaReferences when a referenceDb is given'); + } + // `as const` (a const assertion, not an unsafe cast) pins `modality` to the literal so the object + // satisfies `DurableMediaMeta` without importing the type. + const meta = { + handle: `media://sha256-${'a'.repeat(64)}`, + mimeType: 'image/png', + modality: 'image', + byteLength: 4, + } as const; + // BOTH host-port methods route through the SAME referenceDb — prove each via an observer store over it. + const observer = createMediaReferenceStore(client.db); + await refs.recordRunMedia(meta, 'run-1'); + await refs.reclaimRun('run-1'); + expect(observer.removeRunReferences('run-1')).toBe(0); // reclaimRun cleared the ref from the db + await refs.recordRunMedia(meta, 'run-2'); + expect(observer.removeRunReferences('run-2')).toBe(1); // recordRunMedia wrote the ref to the db + } finally { + client.sqlite.close(); + } + }); + }); }); diff --git a/apps/cli/src/engine/host.ts b/apps/cli/src/engine/host.ts index 6179efc9..085ee2f9 100644 --- a/apps/cli/src/engine/host.ts +++ b/apps/cli/src/engine/host.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { mkdir } from 'node:fs/promises'; import { InMemoryRunStore, @@ -7,6 +8,45 @@ import { type ExecutionHost, type RunStore, } from '@relavium/core'; +import { + FilesystemMediaStore, + createFilesystemMediaWrite, + createMediaReferencePort, + createMediaReferenceStore, + fetchMediaBytes, + type Db, +} from '@relavium/db'; + +/** + * Host media-port roots the CLI resolves per-invocation and injects into {@link createCliHost} (2.S). Each is + * optional + absent-tolerant: an unset root leaves its port `undefined`, and the engine fails the relevant + * operation loud rather than leaking bytes. Passed in (never hard-coded) so the desktop/VS Code hosts reuse + * the same seam with their own roots. + */ +export interface CliMediaOptions { + /** + * The `save_to` write-port scope root the CALLER resolves and passes — the `run`/`gate` paths pass + * `/.relavium/runs/` (project-relative). The port `realpath`+`commonpath`-jails every write under it + * (symlinks off, ADR-0044 §2). Absent ⇒ no `mediaWrite`, so an `output` node's `save_to` fails the run with + * a clear configuration error (never a silent skip). + */ + readonly saveToRoot?: string; + /** + * The content-addressed media-store (CAS) root the CALLER resolves and passes — the `run`/`gate` paths pass + * `~/.relavium/media/` (global, sha256-addressed, deduped across runs). + * Backs `ExecutionHost.mediaStore` — the de-inline/persist choke point the engine writes produced media to, + * and the same instance the `AgentRunnerDeps.resolveForEgress` re-materialization reads (a handle written by + * one resolves in the other). Absent ⇒ no `mediaStore`, so a media-PRODUCING run fails `media_store_unavailable`. + */ + readonly casRoot?: string; + /** + * The SQLite connection backing the `media_objects`/`media_references` retention + authz junction (2.S reuses + * the 2.H `history.db`). Wires `ExecutionHost.mediaReferences` so the engine records a produced handle's run + * reference at the de-inline choke point and reclaims them at the run's terminal event. Absent ⇒ no port + * (best-effort retention only; never a run-correctness break). + */ + readonly referenceDb?: Db; +} /** Options for {@link createCliHost}. */ export interface CliHostOptions { @@ -21,17 +61,44 @@ export interface CliHostOptions { * in-memory store is a split-backend wiring bug that `createCliHost` rejects at construction. */ readonly checkpointer?: Checkpointer; + /** The media-port roots (2.S) — see {@link CliMediaOptions}. Absent ⇒ a media-producing run fails loud. */ + readonly media?: CliMediaOptions; +} + +/** + * Wire the `save_to` write port, provisioning its jail root LAZILY — on the first actual write, not at host + * construction. The port itself fail-closes when the root is missing (`createFilesystemMediaWrite` `realpath`s it + * on every write, ADR-0044 §2) and never creates it — so it can't be coerced into materializing an arbitrary + * directory; provisioning is the HOST's job. A fresh `relavium run` in a project that has never produced media has + * no `/.relavium/runs/` yet, and the first `save_to` deliverable must land, not fail the run. Doing the + * `mkdir` on EVERY write (rather than eagerly in `createCliHost`) keeps a run WITHOUT any `save_to` from + * requiring cwd write access — durable runs in a read-only environment don't fail at host construction. The + * async `mkdir(recursive)` is idempotent (a ~no-op once the root exists) and runs before + * `createFilesystemMediaWrite`'s `realpath` jail; the await keeps the port fully non-blocking (matching the + * `node:fs/promises` pattern `FilesystemMediaStore.put` uses). The CAS root is NOT provisioned here — `FilesystemMediaStore` lazily `mkdir`s its sharded path + * on `put`. + */ +function wireSaveToPort(saveToRoot: string): ReturnType { + const write = createFilesystemMediaWrite(saveToRoot); + return async (relativePath, bytes, signal) => { + await mkdir(saveToRoot, { recursive: true }); + return write(relativePath, bytes, signal); + }; } /** * A real, node-backed {@link ExecutionHost} for the CLI — wall-clock ISO timestamps, UUID ids * (ADR-0022), `setTimeout` one-shot timers, and the global AbortController. `run` injects the durable * SQLite `RunStore` (2.H); `gate` additionally injects the durable {@link Checkpointer} (2.G) so a fresh - * process can rehydrate a paused run from its persisted events. No `mediaStore` — media host-wiring is **2.S** - * (a media-bearing run fails loud, never leaks bytes). + * process can rehydrate a paused run from its persisted events. The media ports (**2.S**) wire when their + * config is given: `fetchMedia` (SSRF-validated egress, ADR-0043) is always on; `mediaStore` (the CAS + * de-inline/persist choke point), `mediaReferences` (the retention/authz junction), and `mediaWrite` (the + * `save_to` write port) wire from `media.casRoot` / `media.referenceDb` / `media.saveToRoot`. A media-PRODUCING + * run with no `mediaStore` fails loud (`media_store_unavailable`), never a silent byte leak. (`read_media` + * input access is a session feature — deferred to 2.M, so it stays fail-closed unavailable on the `run` path.) * - * The clock/ids/abort/timer are generic Node primitives (no CLI specifics), so this is positioned - * for later extraction to a shared node-host helper the VS Code host can reuse. + * The clock/ids/abort/timer + the media ports are generic Node primitives (no CLI specifics), so this is + * positioned for later extraction to a shared node-host helper the VS Code host can reuse. */ export function createCliHost( store: RunStore = new InMemoryRunStore(), @@ -46,6 +113,17 @@ export function createCliHost( 'createCliHost: a checkpointer requires an explicit durable RunStore (the checkpointer must reconstruct from the same store the run persists to)', ); } + // Construct each media port ONCE from its root/handle (a port is absent when its config is). The single + // `FilesystemMediaStore` instance is THE store `host.mediaStore` exposes and `resolveForEgress` reads — a + // handle put by the de-inline choke point must resolve in the failover re-materialization (one CAS, ADR-0042). + const media = options?.media; + const mediaStore = + media?.casRoot === undefined ? undefined : new FilesystemMediaStore(media.casRoot); + const mediaReferences = + media?.referenceDb === undefined + ? undefined + : createMediaReferencePort(createMediaReferenceStore(media.referenceDb)); + const mediaWrite = media?.saveToRoot === undefined ? undefined : wireSaveToPort(media.saveToRoot); return { clock: { now: () => new Date().toISOString() }, ids: { newId: () => randomUUID() }, @@ -62,5 +140,28 @@ export function createCliHost( clearTimeout(timer); }; }, + // The host media-egress mechanism (1.AF/D9, ADR-0043): re-host a public-HTTPS `url` media source to a + // handle via `@relavium/db`'s `fetchMediaBytes` — the SSRF-validated, size-bounded connect, canonically + // homed there (see ADR-0043 §2-3 / the `media-egress.ts` header + its test suite). The wiring + // rationale: `allowPrivate: false` is the default-deny posture (the BYOK local-endpoint opt-in is deferred, + // security-review.md); the engine owns the `maxBytes` policy + the run `AbortSignal`; always wired (a + // text-only run never invokes it); `signal` is spread conditionally so an absent one is OMITTED, not + // assigned `undefined` (which `exactOptionalPropertyTypes` rejects). + fetchMedia: (url, maxBytes, signal) => + fetchMediaBytes(url, { + maxBytes, + allowPrivate: false, + ...(signal === undefined ? {} : { signal }), + }), + // The media ports (2.S), each spread in only when its config (above) was supplied — `undefined` is OMITTED, + // not assigned (the host fields are `?:`, exactOptionalPropertyTypes). `mediaStore` (CAS de-inline/persist, + // ADR-0042) + `mediaReferences` (retention/authz junction) + `mediaWrite` (`save_to` write port, ADR-0044 + // §2; realpath+commonpath jail, symlinks off — the engine resolves the `{{ run.id }}`-only template + the + // produced handle's bytes and hands `(relativePath, bytes)` here). Absent `mediaStore` ⇒ a media-PRODUCING + // run fails `media_store_unavailable` (never a silent byte leak); absent `mediaWrite` ⇒ a `save_to` fails + // the run (never a silent skip — it is a real deliverable). + ...(mediaStore === undefined ? {} : { mediaStore }), + ...(mediaReferences === undefined ? {} : { mediaReferences }), + ...(mediaWrite === undefined ? {} : { mediaWrite }), }; } diff --git a/apps/cli/src/engine/media-gc.test.ts b/apps/cli/src/engine/media-gc.test.ts new file mode 100644 index 00000000..1b7f2c03 --- /dev/null +++ b/apps/cli/src/engine/media-gc.test.ts @@ -0,0 +1,294 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createClient, + createMediaReferenceStore, + FilesystemMediaStore, + runMigrations, + type DbClient, +} from '@relavium/db'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { seedRun } from '../test-support.js'; +import { runHostMediaGc, sweepHostMediaBestEffort, type MediaGcDeps } from './media-gc.js'; + +const H = (c: string): string => `media://sha256-${c.repeat(64)}`; + +/** A fake CAS recording deletes + returning a fixed `{handle, mtimeMs}` listing — isolates the orchestration. */ +function fakeCas(handles: Array<{ handle: string; mtimeMs: number }> = []): { + store: MediaGcDeps['casStore']; + deleted: string[]; +} { + const deleted: string[] = []; + return { + deleted, + store: { + delete: (handle) => { + deleted.push(handle); + return Promise.resolve(); + }, + listHandles: () => Promise.resolve(handles), + }, + }; +} + +/** A fake reference junction; `removeCount` lets a test exercise the `> 0` reclaim guard. */ +function fakeRefs(over: { + expired?: string[]; + objectHandles?: string[]; + runRefRunIds?: string[]; + removeCount?: (runId: string) => number; +}): { refs: MediaGcDeps['references']; removed: string[] } { + const removed: string[] = []; + return { + removed, + refs: { + reclaimExpired: () => over.expired ?? [], + removeRunReferences: (runId) => { + removed.push(runId); + return over.removeCount?.(runId) ?? 1; + }, + listObjectHandles: () => over.objectHandles ?? [], + runReferenceRunIds: () => over.runRefRunIds ?? [], + }, + }; +} + +/** Base deps with the orphan age-guard disabled (orphanMinAgeMs 0) and a fixed clock, so a test opts into age. */ +function baseDeps(over: Partial): MediaGcDeps { + return { + casStore: fakeCas().store, + references: fakeRefs({}).refs, + isReclaimableRun: () => true, + hasOtherActiveRuns: () => false, + graceMs: 0, + now: () => 10_000, + orphanMinAgeMs: 0, + currentRunId: 'current', + ...over, + }; +} + +describe('runHostMediaGc (2.S/D-GC, ADR-0042 §4)', () => { + it('reclaim-retry: sweeps ONLY reclaimable (settled), non-current runs with lingering run-refs', async () => { + const { refs, removed } = fakeRefs({ runRefRunIds: ['settled-a', 'active-b', 'current'] }); + const report = await runHostMediaGc( + baseDeps({ + references: refs, + isReclaimableRun: (id) => id === 'settled-a', // active-b is live; current is excluded by id + }), + ); + expect(removed).toEqual(['settled-a']); + expect(report.reclaimedRuns).toBe(1); + }); + + it('reclaim-retry: a run whose removeRunReferences returns 0 is NOT counted as reclaimed', async () => { + const { refs } = fakeRefs({ + runRefRunIds: ['a', 'b'], + removeCount: (id) => (id === 'a' ? 0 : 1), // `a` had no rows left to remove + }); + const report = await runHostMediaGc(baseDeps({ references: refs, currentRunId: 'x' })); + expect(report.reclaimedRuns).toBe(1); // only `b` + }); + + it('grace-GC: deletes the CAS bytes of every grace-expired handle reclaimExpired returns', async () => { + const cas = fakeCas(); + const { refs } = fakeRefs({ expired: [H('a'), H('b')] }); + const report = await runHostMediaGc(baseDeps({ casStore: cas.store, references: refs })); + expect(cas.deleted).toEqual([H('a'), H('b')]); + expect(report.graceReclaimed).toBe(2); + }); + + it('orphan-sweep: deletes settled row-less blobs (no media_objects row), keeps the known ones', async () => { + const cas = fakeCas([ + { handle: H('a'), mtimeMs: 0 }, + { handle: H('b'), mtimeMs: 0 }, + { handle: H('c'), mtimeMs: 0 }, + ]); + const { refs } = fakeRefs({ objectHandles: [H('a')] }); // only `a` has a row + const report = await runHostMediaGc(baseDeps({ casStore: cas.store, references: refs })); + expect(cas.deleted).toEqual([H('b'), H('c')]); // the row-less orphans, never `a` + expect(report.orphansDeleted).toBe(2); + expect(report.orphanSweepRan).toBe(true); + }); + + it('orphan-sweep: age-guard SKIPS a fresh blob, DELETES one exactly at the cutoff (strict > skip)', async () => { + // now 10_000, orphanMinAgeMs 5_000 ⇒ settledBefore 5_000. The skip is `mtimeMs > settledBefore`, so a blob + // AT the boundary (mtimeMs === 5_000, age exactly the window) is deleted; a younger one is protected. + const cas = fakeCas([ + { handle: H('old'), mtimeMs: 1_000 }, // older than the window → deleted + { handle: H('boundary'), mtimeMs: 5_000 }, // exactly at the window (age === orphanMinAgeMs) → deleted + { handle: H('fresh'), mtimeMs: 9_999 }, // within the window → protected + ]); + const { refs } = fakeRefs({ objectHandles: [] }); + const report = await runHostMediaGc( + baseDeps({ casStore: cas.store, references: refs, now: () => 10_000, orphanMinAgeMs: 5_000 }), + ); + expect(cas.deleted).toEqual([H('old'), H('boundary')]); // the fresh blob alone is protected + expect(report.orphansDeleted).toBe(2); + }); + + it('orphan-sweep: SKIPPED entirely while another run is active (protects a concurrent writer)', async () => { + const cas = fakeCas([{ handle: H('a'), mtimeMs: 0 }]); + const report = await runHostMediaGc( + baseDeps({ casStore: cas.store, hasOtherActiveRuns: () => true }), + ); + expect(cas.deleted).toEqual([]); + expect(report.orphansDeleted).toBe(0); + expect(report.orphanSweepRan).toBe(false); + }); + + it('runs the steps in order: reclaim retry → grace GC → orphan sweep (the fresh-window ordering)', async () => { + // The grace GC (step 2) must run AFTER the reclaim retry (step 1) so a handle the retry just dropped to zero + // refs keeps the fresh window removeRunReferences gave it, rather than being reclaimed the same pass. The + // invariant is structural (unobservable in one pass — the handle is referenced when grace would otherwise + // run, or fresh-windowed after), so pin the CALL ORDER directly. + const calls: string[] = []; + const refs: MediaGcDeps['references'] = { + runReferenceRunIds: () => ['settled'], + removeRunReferences: () => { + calls.push('removeRunReferences'); + return 1; + }, + reclaimExpired: () => { + calls.push('reclaimExpired'); + return []; + }, + listObjectHandles: () => { + calls.push('listObjectHandles'); + return []; + }, + }; + await runHostMediaGc( + baseDeps({ references: refs, isReclaimableRun: () => true, currentRunId: 'x' }), + ); + expect(calls).toEqual(['removeRunReferences', 'reclaimExpired', 'listObjectHandles']); + }); +}); + +describe('sweepHostMediaBestEffort (the run/gate run-end wrapper — real db + CAS)', () => { + let client: DbClient; + let casRoot: string; + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + casRoot = mkdtempSync(join(tmpdir(), 'relavium-gc-cas-')); + }); + afterEach(() => { + try { + client.sqlite.close(); + } catch { + // already closed by a test + } + rmSync(casRoot, { recursive: true, force: true }); + }); + + it('returns a report on the happy path (empty db + CAS ⇒ nothing reclaimed)', async () => { + const report = await sweepHostMediaBestEffort({ + db: client.db, + casRoot, + currentRunId: 'run-1', + }); + expect(report).toEqual({ + reclaimedRuns: 0, + graceReclaimed: 0, + orphansDeleted: 0, + orphanSweepRan: true, + }); + }); + + it('swallows a fault and returns undefined — a GC failure is never a run-correctness break', async () => { + client.sqlite.close(); // any store query now throws — the wrapper must not propagate it + const report = await sweepHostMediaBestEffort({ + db: client.db, + casRoot, + currentRunId: 'run-1', + }); + expect(report).toBeUndefined(); + }); + + it('reclaims a terminal run’s lingering ref but DEFERS the orphan sweep while another run is active', async () => { + await seedRun(client.db, { slug: 'wf', runId: 'terminal-run', state: 'completed' }); + await seedRun(client.db, { slug: 'wf', runId: 'active-run', state: 'running' }); + const refs = createMediaReferenceStore(client.db); + refs.recordObject({ handle: H('a'), mimeType: 'image/png', modality: 'image', byteLength: 5 }); + refs.addReference(H('a'), 'run', 'terminal-run'); // a crash-dropped run-ref on the terminal run + refs.addReference(H('a'), 'run', 'active-run'); // a legitimately-live ref on the running run + + const report = await sweepHostMediaBestEffort({ + db: client.db, + casRoot, + currentRunId: 'gate-run', + orphanMinAgeMs: 0, + }); + expect(report?.reclaimedRuns).toBe(1); // ONLY the terminal run — never the active (running) one + expect(report?.orphanSweepRan).toBe(false); // the active run defers the sweep (TOCTOU protection) + // The active run keeps its ref; the terminal run's ref is gone. + expect(refs.runReferenceRunIds()).toEqual(['active-run']); + }); + + it('PRESERVES a PAUSED run’s media ref and DEFERS the orphan sweep — paused media must survive a cross-process resume', async () => { + // The single highest-stakes invariant of the deletion surface: a paused run's media MUST survive — it backs a + // human-gate / budget cross-process resume. The protection rests entirely on 'paused' being ABSENT from + // TERMINAL_RUN_STATUSES (so its run-ref is never reclaimed) and PRESENT in the active set (so it defers the + // destructive orphan sweep). Pin it at the real-DB integration level so a future status-set regression that + // would delete a paused run's media mid-resume fails CI. + await seedRun(client.db, { + slug: 'wf', + runId: 'paused-run', + state: 'paused', + gate: { gateId: 'g1', gateType: 'approval' }, // parked on a human gate — the canonical resume scenario + }); + const refs = createMediaReferenceStore(client.db); + refs.recordObject({ handle: H('a'), mimeType: 'image/png', modality: 'image', byteLength: 5 }); + refs.addReference(H('a'), 'run', 'paused-run'); // a legitimately-live ref on the paused run + const cas = new FilesystemMediaStore(casRoot); + const orphan = await cas.put(new Uint8Array([9, 9, 9])); // a row-less blob the sweep WOULD delete if it ran + + const report = await sweepHostMediaBestEffort({ + db: client.db, + casRoot, + currentRunId: 'gate-run', + orphanMinAgeMs: 0, // even with the age-guard off, the paused run must defer the sweep + }); + expect(report?.reclaimedRuns).toBe(0); // the paused run is NON-terminal — its ref is NEVER reclaimed + expect(report?.orphanSweepRan).toBe(false); // a paused run counts as active — the destructive sweep defers + expect(report?.orphansDeleted).toBe(0); + expect(refs.runReferenceRunIds()).toEqual(['paused-run']); // the ref survives for the resume + await expect(cas.get(orphan)).resolves.toBeDefined(); // sweep deferred ⇒ even a row-less blob survives + }); + + it('reclaims the run-refs of a GONE run (no live history row — soft-deleted / pruned)', async () => { + // A run-ref whose run is absent from live history (`loadRun` undefined) is a retention leak: its ref kept the + // handle's refcount > 0 forever. The reclaim retry treats a GONE run as reclaimable (status === undefined), + // so its lingering ref is swept. (No `seedRun` for `pruned-run` → no `runs` row ⇒ loadRun returns undefined.) + const refs = createMediaReferenceStore(client.db); + refs.recordObject({ handle: H('a'), mimeType: 'image/png', modality: 'image', byteLength: 5 }); + refs.addReference(H('a'), 'run', 'pruned-run'); + + const report = await sweepHostMediaBestEffort({ + db: client.db, + casRoot, + currentRunId: 'gate-run', + orphanMinAgeMs: 0, + }); + expect(report?.reclaimedRuns).toBe(1); // the gone run's lingering ref was reclaimed + expect(refs.runReferenceRunIds()).toEqual([]); // ...and no run-ref lingers + }); + + it('sweeps a row-less CAS orphan when no run is active (the current run is excluded)', async () => { + const cas = new FilesystemMediaStore(casRoot); + const orphan = await cas.put(new Uint8Array([1, 2, 3])); // bytes written, NO media_objects row recorded + const report = await sweepHostMediaBestEffort({ + db: client.db, + casRoot, + currentRunId: 'run-1', + orphanMinAgeMs: 0, // treat the just-written blob as settled for the test + }); + expect(report?.orphanSweepRan).toBe(true); + expect(report?.orphansDeleted).toBe(1); + await expect(cas.get(orphan)).rejects.toThrow(); // the orphan bytes are gone + }); +}); diff --git a/apps/cli/src/engine/media-gc.ts b/apps/cli/src/engine/media-gc.ts new file mode 100644 index 00000000..d0bfe248 --- /dev/null +++ b/apps/cli/src/engine/media-gc.ts @@ -0,0 +1,164 @@ +import { + createMediaReferenceStore, + createRunHistoryReader, + FilesystemMediaStore, + type Db, + type MediaReferenceStore, +} from '@relavium/db'; +import type { RunStatus } from '@relavium/shared'; + +/** The terminal `runs.status` set — a run here will never be resumed, so its lingering `run`-refs are reclaimable. + * Typed as `Set` (not `Set`) so a misspelled status is a compile error and `.has(status)` + * narrows against the closed run-status union. */ +const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']); + +/** + * The ADR-0042 §4 default grace window (7 days) before a zero-reference handle's bytes are reclaimed. The + * `[defaults].media_gc_grace_days` config key is forward-declared (P4/D11) but not yet wired; until it lands the + * host GC uses this default. + */ +export const DEFAULT_MEDIA_GC_GRACE_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * The minimum age a row-less CAS blob must reach before the orphan sweep deletes it (1 hour). A blob younger than + * this may be a CONCURRENT run's freshly-`put` blob whose `recordObject` has not landed yet — deleting it would + * destroy live media. A genuine crash-orphan simply ages past this window and is reclaimed on a later sweep. This + * (independent of wall-clock timing) closes the check-then-sweep TOCTOU the `hasOtherActiveRuns` gate alone leaves. + */ +export const DEFAULT_ORPHAN_MIN_AGE_MS = 60 * 60 * 1000; + +export interface MediaGcDeps { + /** The run's CAS — the byte-reclamation + orphan-sweep delete from / enumerate it (a `FilesystemMediaStore`). */ + readonly casStore: Pick; + /** The reference junction the reclaim-retry + grace-GC + orphan-detection read/mutate. */ + readonly references: Pick< + MediaReferenceStore, + 'reclaimExpired' | 'removeRunReferences' | 'listObjectHandles' | 'runReferenceRunIds' + >; + /** True iff the run is SETTLED — terminal OR gone (soft-deleted / absent from live history) — so its lingering + * `run`-refs are safe to reclaim. NEVER true for an in-flight / paused run (whose media must survive a resume). */ + readonly isReclaimableRun: (runId: string) => boolean; + /** True iff ANOTHER run is still active (running / paused) — gates the orphan sweep off so a concurrent writer's + * freshly-`put` (not-yet-`recordObject`'d) blob is never mistaken for a row-less orphan and deleted. */ + readonly hasOtherActiveRuns: () => boolean; + /** The grace window before a zero-ref handle's bytes are reclaimed (ADR-0042 §4c). */ + readonly graceMs: number; + /** Wall clock (ms) — for the orphan age-guard. Injected so tests are deterministic. */ + readonly now: () => number; + /** The minimum age a row-less blob must reach before the orphan sweep deletes it (concurrent-writer guard). */ + readonly orphanMinAgeMs: number; + /** The in-flight run — never reclaimed here (the engine owns its own terminal sweep at the terminal event). */ + readonly currentRunId?: string; +} + +export interface MediaGcReport { + /** Settled (terminal / gone) runs whose lingering `run`-refs the retry swept (a crash had dropped the sweep). */ + readonly reclaimedRuns: number; + /** Handles whose bytes were reclaimed past the grace window (ADR-0042 §4c). */ + readonly graceReclaimed: number; + /** Row-less CAS blobs (no `media_objects` row, past the settle age) deleted by the orphan sweep. */ + readonly orphansDeleted: number; + /** Whether the orphan sweep ran (skipped when another run is active, to protect a concurrent writer). */ + readonly orphanSweepRan: boolean; +} + +/** + * The host media garbage collection (2.S/D-GC, ADR-0042 §4) — a best-effort, run-end ("keyed on the terminal run + * event") pass the CLI owns (the engine signals the terminal; the host runs the mechanism). Three ordered steps: + * + * 1. **Clean-terminal reclaim retry** — re-attempt the terminal sweep (`removeRunReferences`) for every run + * holding a lingering `run`-ref whose run is SETTLED (terminal or gone, {@link MediaGcDeps.isReclaimableRun} + * — a crash had dropped the inline sweep). NEVER the current run (the engine reclaims it) and NEVER an + * in-flight / paused run (its media must live). + * 2. **Grace-window GC** — `reclaimExpired(graceMs)` soft-deletes the rows of zero-ref handles past the grace + * window and returns them; delete each one's CAS bytes. AFTER step 1 so a handle just dropped to zero refs + * gets its fresh grace window (its `last_referenced_at` was refreshed) rather than being reclaimed this pass. + * 3. **CAS-orphan sweep** — delete every CAS blob with NO `media_objects` row that is also older than + * `orphanMinAgeMs` (a crash between `put` and `recordObject` left row-less bytes). Gated off while another + * run is active, AND age-guarded, so a concurrent writer's fresh in-flight blob is never swept. + * + * Best-effort: the CALLER swallows a throw (a GC failure is never a run-correctness break, ADR-0042 §3). + */ +export async function runHostMediaGc(deps: MediaGcDeps): Promise { + // 1. Clean-terminal reclaim retry — only settled (terminal/gone), non-current runs. + let reclaimedRuns = 0; + for (const runId of deps.references.runReferenceRunIds()) { + if (runId === deps.currentRunId || !deps.isReclaimableRun(runId)) { + continue; + } + if (deps.references.removeRunReferences(runId) > 0) { + reclaimedRuns += 1; + } + } + + // 2. Grace-window GC — reclaim the bytes of grace-expired zero-ref handles. `reclaimExpired` soft-deletes the + // rows synchronously and returns the handles; the CAS unlinks run concurrently (independent deletes). + // Known best-effort gap (ADR-0042 §3): between the soft-delete and a delete, a concurrent process could + // `recordObject` the same content-addressed handle (ON CONFLICT clears `deleted_at`), and the unlink would + // then drop bytes that are live again — leaving a row with no file. It requires byte-identical content + // re-produced inside a sub-ms window AFTER a full `graceMs` (7-day) zero-ref period, so it is negligible + // today; if `graceMs` is ever shortened materially, gate each delete on a re-verify SELECT (skip a handle + // whose `deleted_at` is NULL again). + const expired = deps.references.reclaimExpired(deps.graceMs); + await Promise.all(expired.map((handle) => deps.casStore.delete(handle))); + + // 3. CAS-orphan sweep — skip entirely while another run could be mid-write; age-guard each candidate so a + // fresh row-less blob (a concurrent run's, not yet recordObject'd) is never deleted. + let orphansDeleted = 0; + const orphanSweepRan = !deps.hasOtherActiveRuns(); + if (orphanSweepRan) { + const known = new Set(deps.references.listObjectHandles()); + const settledBefore = deps.now() - deps.orphanMinAgeMs; + // A row-less blob is an orphan ONLY if it also aged past `orphanMinAgeMs` — a fresher one may be a concurrent + // run's just-`put` blob whose `recordObject` has not landed yet. Collect, then unlink concurrently. + const orphans = (await deps.casStore.listHandles()) + .filter(({ handle, mtimeMs }) => !known.has(handle) && mtimeMs <= settledBefore) + .map(({ handle }) => handle); + await Promise.all(orphans.map((handle) => deps.casStore.delete(handle))); + orphansDeleted = orphans.length; + } + + return { reclaimedRuns, graceReclaimed: expired.length, orphansDeleted, orphanSweepRan }; +} + +/** + * Wire {@link runHostMediaGc} from the run/gate command context and run it BEST-EFFORT (2.S/D-GC): assemble the + * CAS + reference + run-history dependencies over the open `history.db` and swallow any throw — a GC failure is + * never a run-correctness break (ADR-0042 §3). Called once at the TERMINAL of `run` / `gate` (the GC is "keyed on + * the terminal run event" — the callers skip it on a non-terminal `paused` outcome). Returns the + * {@link MediaGcReport} (a future `--verbose` line / tests consume it; the callers ignore it today), or + * `undefined` when the GC threw. + */ +export async function sweepHostMediaBestEffort(args: { + readonly db: Db; + readonly casRoot: string; + readonly currentRunId: string; + readonly graceMs?: number; + readonly orphanMinAgeMs?: number; + readonly now?: () => number; +}): Promise { + try { + // The cross-workflow read API (loadRun status + listActiveRuns) is built from the db handle alone. + const reader = createRunHistoryReader(args.db); + const now = args.now ?? Date.now; + return await runHostMediaGc({ + casStore: new FilesystemMediaStore(args.casRoot), + // One clock governs both the grace cutoff (`reclaimExpired` reads the store's `now`) and the orphan + // age-guard below, so an injected `now` makes the whole pass deterministic. + references: createMediaReferenceStore(args.db, now), + isReclaimableRun: (id) => { + // A run absent from LIVE history (soft-deleted / pruned ⇒ `loadRun` undefined) is GONE — its run-refs are + // safe to reclaim, just like a terminal run's. An in-flight / paused run (a live non-terminal row) is kept. + const status = reader.loadRun(id)?.status; + return status === undefined || TERMINAL_RUN_STATUSES.has(status); + }, + hasOtherActiveRuns: () => reader.listActiveRuns().some((run) => run.id !== args.currentRunId), + graceMs: args.graceMs ?? DEFAULT_MEDIA_GC_GRACE_MS, + now, + orphanMinAgeMs: args.orphanMinAgeMs ?? DEFAULT_ORPHAN_MIN_AGE_MS, + currentRunId: args.currentRunId, + }); + } catch { + return undefined; // best-effort — a GC failure is never a run-correctness break (ADR-0042 §3) + } +} diff --git a/apps/cli/src/engine/media-wiring.test.ts b/apps/cli/src/engine/media-wiring.test.ts new file mode 100644 index 00000000..c2f2d3c8 --- /dev/null +++ b/apps/cli/src/engine/media-wiring.test.ts @@ -0,0 +1,202 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +import { + createClient, + createModelCatalogStore, + createProviderStore, + ModelCatalogCapabilitiesError, + runMigrations, + type DbClient, +} from '@relavium/db'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { globalConfigDir } from '../config/paths.js'; +import type { ResolvedConfig } from '../config/resolve.js'; +import { CHAT_TEXT_CAPABILITY_FLAGS, GENERATIVE_IMAGE_CAPABILITY_FLAGS } from '../test-support.js'; +import { buildMediaEngineWiring } from './media-wiring.js'; + +const EMPTY_CONFIG: ResolvedConfig = { + updateChannel: undefined, + defaultModel: undefined, + fsScope: undefined, + maxTokensEstimate: undefined, + mediaCostEstimate: undefined, + variables: {}, + mcpServers: [], +}; + +describe('buildMediaEngineWiring (2.S — the shared run/gate media wiring)', () => { + let client: DbClient; + let providerId: string; + const dbDeps = { uuid: () => randomUUID(), now: () => Date.now() }; + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + providerId = createProviderStore(client.db, dbDeps).upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }).id; + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + }); + afterEach(() => { + client.sqlite.close(); + }); + + it('roots the CAS under the home dir + save_to under the cwd, and reuses the db for references', () => { + const wiring = buildMediaEngineWiring(client.db, '/home/u', '/proj', EMPTY_CONFIG); + // CAS is global under the home `.relavium/`; save_to is project-relative to the run/resume cwd; one db. + expect(wiring.media.casRoot).toBe(join(globalConfigDir('/home/u'), 'media')); + expect(wiring.media.saveToRoot).toBe(join('/proj', '.relavium', 'runs')); + expect(wiring.media.referenceDb).toBe(client.db); + }); + + it('surfaces the catalog routing over the db (seeded generative model routes, unknown is undefined)', () => { + const wiring = buildMediaEngineWiring(client.db, '/home/u', '/proj', EMPTY_CONFIG); + expect(wiring.resolveMediaSurface('gpt-image-1')).toBe('generative'); + expect(wiring.resolveMediaSurface('not-in-catalog')).toBeUndefined(); + }); + + it('forwards the configured media_cost_estimate (the populated arm), and undefined when unset', () => { + expect( + buildMediaEngineWiring(client.db, '/home/u', '/proj', { + ...EMPTY_CONFIG, + mediaCostEstimate: { image: 3, audio: 7 }, + }).mediaCostEstimate, + ).toEqual({ image: 3, audio: 7 }); + expect( + buildMediaEngineWiring(client.db, '/home/u', '/proj', EMPTY_CONFIG).mediaCostEstimate, + ).toBeUndefined(); + }); + + describe('workflowModelCatalog (the D15 load-check projection — capabilities → CapabilityFlags)', () => { + it('projects a row with a well-formed chat capabilities blob into validated CapabilityFlags', () => { + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'chat-text', + displayName: 'Chat Text', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'chat', + capabilities: CHAT_TEXT_CAPABILITY_FLAGS, + }); + const flags = buildMediaEngineWiring( + client.db, + '/home/u', + '/proj', + EMPTY_CONFIG, + ).workflowModelCatalog('chat-text'); + // Round-trips through CapabilityFlagsSchema — the load-check reads `media.surface` + `outputCombinations`. + expect(flags?.media.surface).toBe('chat'); + expect(flags?.media.outputCombinations).toEqual([['text']]); + expect(flags?.tools).toBe(true); + }); + + it('carries media.surface from the capabilities blob — a generative row projects surface generative', () => { + // The projection reads `media.surface` from the capabilities JSON (NOT the DB `mediaSurface` column — + // resolveMediaSurface owns that), so the load-check's generative branch (validate-catalog.ts) keys on it. + // A regression that dropped `media.surface` would default to 'chat' and silently take the wrong branch. + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'gen-image', + displayName: 'Gen Image', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + capabilities: GENERATIVE_IMAGE_CAPABILITY_FLAGS, + }); + const flags = buildMediaEngineWiring( + client.db, + '/home/u', + '/proj', + EMPTY_CONFIG, + ).workflowModelCatalog('gen-image'); + expect(flags?.media.surface).toBe('generative'); + expect(flags?.media.outputCombinations).toEqual([]); + }); + + it('defers (undefined) for a model absent from the catalog', () => { + expect( + buildMediaEngineWiring(client.db, '/home/u', '/proj', EMPTY_CONFIG).workflowModelCatalog( + 'not-in-catalog', + ), + ).toBeUndefined(); + }); + + it('defers (undefined) for a row whose capabilities fail CapabilityFlagsSchema — never throws', () => { + // Seed a model with an explicit empty `capabilities: {}` (no required flags) so the test is self-contained, + // not coupled to the beforeEach row's default. `safeParse({})` fails ⇒ the projection must defer, not throw. + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'empty-caps', + displayName: 'Empty Caps', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + capabilities: {}, + }); + expect( + buildMediaEngineWiring(client.db, '/home/u', '/proj', EMPTY_CONFIG).workflowModelCatalog( + 'empty-caps', + ), + ).toBeUndefined(); + }); + + it('isolates a corrupt (non-object) capabilities row to that model — defers, not a whole-catalog throw', () => { + // `getByModelId` THROWS a ModelCatalogCapabilitiesError on a non-object capabilities column (the store + // contract the catch keys on); the projection's per-model catch must degrade THIS model to undefined without + // sinking the load-check. Corrupt the column directly (the upsert can only write an object), then assert it. + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'corrupt-caps', + displayName: 'Corrupt Caps', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + capabilities: { ...CHAT_TEXT_CAPABILITY_FLAGS }, + }); + client.sqlite + .prepare(`UPDATE model_catalog SET capabilities = '[]' WHERE model_id = ?`) + .run('corrupt-caps'); + const catalog = buildMediaEngineWiring( + client.db, + '/home/u', + '/proj', + EMPTY_CONFIG, + ).workflowModelCatalog; + expect(() => catalog('corrupt-caps')).not.toThrow(); + expect(catalog('corrupt-caps')).toBeUndefined(); + }); + + it('propagates a genuine store fault (a closed db) instead of masking it as a defer', () => { + // The narrowed catch swallows ONLY the store's documented parse faults; a real DB error (here, a closed + // connection) must surface, not be degraded to a clean "model unresolvable" that slips a node past the gate. + const local = createClient(':memory:'); + runMigrations(local.db); + const catalog = buildMediaEngineWiring( + local.db, + '/home/u', + '/proj', + EMPTY_CONFIG, + ).workflowModelCatalog; + local.sqlite.close(); // any subsequent query throws a generic "database connection is not open" Error + let caught: unknown; + try { + catalog('any-model'); + } catch (err) { + caught = err; + } + // It SURFACED (not swallowed to undefined) AND is a non-domain fault — NOT the ModelCatalogCapabilitiesError + // the catch swallows. This is the whole point: better-sqlite3 throws a TypeError on a closed connection, so a + // by-type narrow would misclassify it as a defer; the typed-domain narrow lets it through. + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBeInstanceOf(ModelCatalogCapabilitiesError); + }); + }); +}); diff --git a/apps/cli/src/engine/media-wiring.ts b/apps/cli/src/engine/media-wiring.ts new file mode 100644 index 00000000..1ebe753c --- /dev/null +++ b/apps/cli/src/engine/media-wiring.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +import type { WorkflowModelCatalog } from '@relavium/core'; +import { + createModelCatalogStore, + ModelCatalogCapabilitiesError, + type Db, + type ModelCatalogRecord, + type ModelCatalogStore, +} from '@relavium/db'; +import { CapabilityFlagsSchema } from '@relavium/llm'; +import type { MediaCostEstimate, MediaSurface } from '@relavium/shared'; + +import { globalConfigDir } from '../config/paths.js'; +import type { ResolvedConfig } from '../config/resolve.js'; +import type { CliMediaOptions } from './host.js'; + +/** + * The 2.S media wiring `run` and `gate` share (ADR-0042/0044/0045): the host media-port roots + the catalog + * routing projection + the configured per-modality cost estimate — assembled once over the durable + * `~/.relavium/history.db` connection. Both commands build it identically; extracting it keeps the two call + * sites from drifting (a swapped root in one but not the other). + */ +export interface MediaEngineWiring { + /** The host media-port roots ({@link CliMediaOptions}) the command passes to `createCliHost`. */ + readonly media: CliMediaOptions; + /** The `AgentRunnerDeps.resolveMediaSurface` projection over the `model_catalog` (ADR-0045 §1). */ + readonly resolveMediaSurface: (model: string) => MediaSurface | undefined; + /** + * The `WorkflowModelCatalog` the D15 load-check reads — a model → `CapabilityFlags` lookup over the same + * `model_catalog`. BOTH `run` (a fresh load) and `gate` (a resume — re-validated against the current catalog) + * feed it to `assertWorkflowCatalogValid` (drive.ts), so the two paths reject an incapable node consistently. + */ + readonly workflowModelCatalog: WorkflowModelCatalog; + /** The `[defaults].media_cost_estimate` the command spreads into `BuildEngineOptions` (`undefined` ⇒ omit). */ + readonly mediaCostEstimate: MediaCostEstimate | undefined; +} + +/** + * Project the DB `model_catalog` reader into the engine's {@link WorkflowModelCatalog} — the D15 load-check's + * `(modelId) => CapabilityFlags | undefined` lookup (ADR-0044 §2 / ADR-0045 §1). `@relavium/db` deliberately + * returns the raw `capabilities` JSON object (it never depends on `@relavium/llm`, the `CapabilityFlags` home); + * the HOST validates it against `CapabilityFlagsSchema` here, keeping the engine portable (CLAUDE.md rule 5). + * + * Two per-model "degrade to `undefined`" (defer) paths — never a whole-catalog abort, so one bad row can't sink + * the load-check for a valid sibling model (the runtime FallbackChain pre-skip stays the backstop): + * - `getByModelId` throws a typed {@link ModelCatalogCapabilitiesError} on a corrupt `capabilities` row + * (non-JSON / non-object); the `catch` swallows ONLY that type, isolating the corrupt row to this model. A + * throw of ANY OTHER kind is a genuine store/DB fault (a closed/locked connection, an IO error) — NOT a + * capability verdict — so it is rethrown rather than masked as a clean "unresolvable" defer that would slip + * an unchecked node past the load gate. (A bare `instanceof TypeError` would be wrong here — better-sqlite3 + * itself throws a `TypeError` on a closed connection, so the typed domain error is what makes this precise.) + * - a row whose `capabilities` fails `CapabilityFlagsSchema` (a partial / legacy blob) — `safeParse` defers. + * + * A `safeParse` defer is silent fail-open (the runtime FallbackChain pre-skip is the backstop), but it is + * indistinguishable from "model absent" to an operator. When a `warn` sink is supplied, the schema-mismatch defer + * emits a secret-free, per-model-deduped line (model id + Zod issue messages — capability flags carry no secret), + * so a future `CapabilityFlagsSchema` evolution that silently invalidates previously-valid rows is observable. + */ +function createWorkflowModelCatalog( + catalog: ModelCatalogStore, + warn?: (message: string) => void, +): WorkflowModelCatalog { + const warnedModels = new Set(); + return (modelId) => { + let record: ModelCatalogRecord | undefined; + try { + record = catalog.getByModelId(modelId); + } catch (err) { + if (err instanceof ModelCatalogCapabilitiesError) { + return undefined; // a corrupt-capabilities row — defer this one model + } + throw err; // a real store/DB fault is not a defer verdict — surface it + } + if (record === undefined) { + return undefined; + } + const parsed = CapabilityFlagsSchema.safeParse(record.capabilities); + if (!parsed.success) { + if (warn !== undefined && !warnedModels.has(modelId)) { + warnedModels.add(modelId); // one line per model — a model referenced by N nodes warns once + warn( + `media: model '${modelId}' has a capabilities row that failed validation — the D15 load-check defers it (treated as unresolvable). Issues: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`, + ); + } + return undefined; + } + return parsed.data; + }; +} + +/** + * Build the shared media wiring from the open history `db`, the home dir (the global CAS root lives under it), + * the run/resume `cwd` (the project-relative `save_to` root), and the resolved config (the cost estimate). The + * CAS is global (`~/.relavium/media/`, content-addressed + deduped across runs); `save_to` is per-run-isolated + * by the authored `{{ run.id }}` segment under `/.relavium/runs/`; the `media_references` retention + * junction reuses the same `db`. + */ +export function buildMediaEngineWiring( + db: Db, + homeDir: string, + cwd: string, + config: ResolvedConfig, + warn?: (message: string) => void, +): MediaEngineWiring { + const catalog = createModelCatalogStore(db, { uuid: () => randomUUID(), now: () => Date.now() }); + return { + media: { + casRoot: join(globalConfigDir(homeDir), 'media'), + saveToRoot: join(cwd, '.relavium', 'runs'), + referenceDb: db, + }, + resolveMediaSurface: catalog.resolveMediaSurface, + workflowModelCatalog: createWorkflowModelCatalog(catalog, warn), + mediaCostEstimate: config.mediaCostEstimate, + }; +} diff --git a/apps/cli/src/harness/generative-media.e2e.test.ts b/apps/cli/src/harness/generative-media.e2e.test.ts new file mode 100644 index 00000000..a3f41182 --- /dev/null +++ b/apps/cli/src/harness/generative-media.e2e.test.ts @@ -0,0 +1,175 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createClient, + createModelCatalogStore, + createProviderStore, + createRunHistoryStore, + runMigrations, + type DbClient, +} from '@relavium/db'; +import type { LlmProvider } from '@relavium/llm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { runCommand, type RunCommandDeps } from '../commands/run.js'; +import type { ProviderResolver } from '../engine/providers.js'; +import { EXIT_CODES } from '../process/exit-codes.js'; +import type { GlobalOptions } from '../process/options.js'; +import { captureIo, GENERATIVE_IMAGE_CAPABILITY_FLAGS, parseNdjson } from '../test-support.js'; + +/** + * The 2.S headline acceptance (off the M3 critical path): a **generative media-output** workflow runs + * end-to-end on the CLI — `relavium run` over a `media_surface: 'generative'` model that produces an image, + * exercising the REAL host wiring (the catalog `resolveMediaSurface` routing → `generateMedia`, the + * `MediaStore` de-inline to a durable `media://` handle, the containment-checked `save_to` write, and the + * cross-surface "render a produced media handle"). The only stubs are the provider (a deterministic + * `generateMedia` — no network) and the durable db (in-memory). `HOME` is pointed at a tmpdir so the global + * CAS lands there, never the developer's real `~/.relavium`. + */ + +// `gen-model` is routed generative by the seeded catalog; `save` writes the produced image under the run id. +const GENERATIVE_WF = `schema_version: '1.0' +workflow: + id: gen-media-e2e + agents: + - { id: painter, model: gen-model, provider: openai, system_prompt: paint } + nodes: + - { id: start, type: input } + - { id: paint, type: agent, agent_ref: painter, prompt_template: 'draw a cat', output_modalities: ['image'] } + - { id: save, type: output, save_to: 'art/{{ run.id }}.png' } + edges: + - { from: start, to: paint } + - { from: paint, to: save } +`; + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // a stand-in "PNG" payload (sha256-addressed) + +/** A provider flagged generative whose generateMedia returns a base64 image (no network); generate/stream throw. */ +function generativeProvider(): LlmProvider { + return { + id: 'openai', + supports: GENERATIVE_IMAGE_CAPABILITY_FLAGS, + generate: () => Promise.reject(new Error('generate must not run for a generative node')), + stream: (): AsyncIterable => { + throw new Error('stream must not run for a generative node'); + }, + generateMedia: () => + Promise.resolve({ + media: { + type: 'media', + mimeType: 'image/png', + source: { kind: 'base64', data: Buffer.from(PNG_BYTES).toString('base64') }, + }, + raw: {}, + }), + }; +} + +// `os.homedir()` reads `HOME` on POSIX but `USERPROFILE` on Windows — override both so the hermetic home holds +// on every platform (CI), and the global CAS always lands in the tmpdir, never the developer's real `~`. +const HOME_ENV_VARS = ['HOME', 'USERPROFILE'] as const; + +describe('generative media-output — end-to-end on the CLI (2.S acceptance)', () => { + let home: string; + let cwd: string; + let client: DbClient; + const savedHome = new Map(); + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'relavium-gen-home-')); + cwd = mkdtempSync(join(tmpdir(), 'relavium-gen-cwd-')); + for (const v of HOME_ENV_VARS) { + savedHome.set(v, process.env[v]); + process.env[v] = home; + } + client = createClient(':memory:'); + runMigrations(client.db); + const dbDeps = { uuid: () => randomUUID(), now: () => Date.now() }; + const providerId = createProviderStore(client.db, dbDeps).upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }).id; + createModelCatalogStore(client.db, dbDeps).upsert({ + providerId, + modelId: 'gen-model', + displayName: 'Generative Image Model', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', // routes the node to generateMedia (ADR-0045 §1) + capabilities: GENERATIVE_IMAGE_CAPABILITY_FLAGS, + }); + }); + + afterEach(() => { + for (const v of HOME_ENV_VARS) { + const prior = savedHome.get(v); + if (prior === undefined) { + delete process.env[v]; + } else { + process.env[v] = prior; + } + } + client.sqlite.close(); + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + }); + + const providers: ProviderResolver = { + resolveProvider: () => generativeProvider(), + keyFor: () => 'sk-test', + }; + + function globalOptions(over: Partial = {}): GlobalOptions { + return { json: true, color: false, cwd, configPath: undefined, verbosity: 'normal', ...over }; + } + + function deps(io: RunCommandDeps['io']): RunCommandDeps { + return { + io, + global: globalOptions(), + providers, + openRunStore: (workflow) => ({ + store: createRunHistoryStore(client.db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { + slug: workflow.workflow.id, + name: workflow.workflow.id, + definitionJson: JSON.stringify(workflow), + }, + }), + db: client.db, + close: () => {}, + }), + }; + } + + it('produces an image, de-inlines it to a media:// handle, renders the handle, and writes save_to', async () => { + const { io, out } = captureIo(); + const wfPath = join(cwd, 'gen.relavium.yaml'); + writeFileSync(wfPath, GENERATIVE_WF); + + const code = await runCommand({ workflow: wfPath, input: [] }, deps(io)); + expect(code).toBe(EXIT_CODES.success); // the generative run completes end-to-end + + const events = parseNdjson(out()); + const runStarted = events.find((e) => e['type'] === 'run:started'); + const runId = runStarted?.['runId']; + expect(typeof runId).toBe('string'); + + // The agent node's node:completed.output carries a DURABLE media handle (de-inlined, never inline bytes), + // and the --json stream renders it verbatim (the cross-surface "render a produced media handle" leaf). + const json = out(); + expect(json).toMatch(/media:\/\/sha256-[0-9a-f]{64}/); // a content-addressed handle is on the stream + expect(json).not.toContain(Buffer.from(PNG_BYTES).toString('base64')); // ...never the inline base64 bytes + + // The save_to deliverable landed under /.relavium/runs/art/.png with the produced bytes. + const savedPath = join(cwd, '.relavium', 'runs', 'art', `${String(runId)}.png`); + expect(existsSync(savedPath)).toBe(true); + expect(Array.from(readFileSync(savedPath))).toEqual(Array.from(PNG_BYTES)); + }); +}); diff --git a/apps/cli/src/harness/regression.e2e.test.ts b/apps/cli/src/harness/regression.e2e.test.ts index c6a3235a..d3df3e55 100644 --- a/apps/cli/src/harness/regression.e2e.test.ts +++ b/apps/cli/src/harness/regression.e2e.test.ts @@ -328,6 +328,7 @@ describe('engine regression harness (2.K) — offline fixtures over `relavium ru definitionJson: JSON.stringify(workflow), }, }), + db: runClient.db, close: () => {}, }); diff --git a/apps/cli/src/history/open.ts b/apps/cli/src/history/open.ts index 4ce5b1bc..ab7c8da0 100644 --- a/apps/cli/src/history/open.ts +++ b/apps/cli/src/history/open.ts @@ -1,13 +1,19 @@ import { randomUUID } from 'node:crypto'; import type { WorkflowDefinition } from '@relavium/core'; -import { createRunHistoryStore, type RunHistoryStore } from '@relavium/db'; +import { createRunHistoryStore, type Db, type RunHistoryStore } from '@relavium/db'; import { openLocalDb } from '../db/open.js'; /** An opened history store plus the handle to close its SQLite connection at run end. */ export interface OpenedHistory { readonly store: RunHistoryStore; + /** + * The same `~/.relavium/history.db` connection the store runs on (2.S reuses it for the `model_catalog` + * reader + the `media_references` retention junction, ADR-0050) — so the catalog/media ports share one + * connection with run history, closed once by {@link close}. + */ + readonly db: Db; readonly close: () => void; } @@ -29,5 +35,5 @@ export function openHistoryStore(workflow: WorkflowDefinition, homeDir: string): definitionJson: JSON.stringify(workflow), }, }); - return { store, close }; + return { store, db, close }; } diff --git a/apps/cli/src/render/renderer.test.ts b/apps/cli/src/render/renderer.test.ts index eb89a088..8fab26ea 100644 --- a/apps/cli/src/render/renderer.test.ts +++ b/apps/cli/src/render/renderer.test.ts @@ -107,6 +107,32 @@ describe('createPlainRenderer', () => { ); expect(out()).toBe(''); }); + + it('surfaces a produced media handle (handle-only, never bytes) under the node:completed line (2.S)', () => { + const { io, out } = captureIo(); + const handle = `media://sha256-${'a'.repeat(64)}`; + createPlainRenderer(io).onEvent( + ev({ + type: 'node:completed', + nodeId: 'painter', + output: { + content: [ + { + type: 'media', + mimeType: 'image/png', + source: { kind: 'handle', ref: handle }, + byteLength: 9, + }, + ], + }, + tokensUsed: { input: 0, output: 0 }, + durationMs: 0, + }), + ); + const text = out(); + expect(text).toContain('ok painter'); + expect(text).toContain(`◆ image/png ${handle}`); // the durable handle, indented under the node line + }); }); describe('createJsonRenderer', () => { @@ -207,4 +233,31 @@ describe('createJsonRenderer', () => { // nothing, and has no path to unwrap the { secret: true, ref } masked shape into a raw value. expect(JSON.parse(out().trim())).toEqual(event); }); + + it('carries a produced media handle verbatim in node:completed.output (the --json leaf of the acceptance)', () => { + const { io, out } = captureIo(); + const handle = `media://sha256-${'a'.repeat(64)}`; + // The engine de-inlines bytes to a handle BEFORE the event, so node:completed.output is already handle-only; + // the NDJSON renderer emits it verbatim, so a machine consumer reads the produced handle off the stream. + const event = ev({ + type: 'node:completed', + nodeId: 'painter', + output: { + content: [ + { + type: 'media', + mimeType: 'image/png', + source: { kind: 'handle', ref: handle }, + byteLength: 9, + }, + ], + }, + tokensUsed: { input: 0, output: 0 }, + durationMs: 0, + }); + createJsonRenderer(io).onEvent(event); + const line = out().trim(); + expect(line).toContain(handle); // the handle is on the machine stream... + expect(JSON.parse(line)).toEqual(event); // ...verbatim (the renderer neither inlines bytes nor drops it) + }); }); diff --git a/apps/cli/src/render/renderer.ts b/apps/cli/src/render/renderer.ts index b9c054fe..26e5b31e 100644 --- a/apps/cli/src/render/renderer.ts +++ b/apps/cli/src/render/renderer.ts @@ -1,6 +1,7 @@ -import type { RunEvent } from '@relavium/shared'; +import { collectDurableMediaHandles, type RunEvent } from '@relavium/shared'; import type { CliIo } from '../process/io.js'; +import { formatProducedMedia } from './tui/format.js'; /** * A renderer consumes the run's canonical {@link RunEvent} stream. The renderers below sit behind this one @@ -62,8 +63,15 @@ function describe(event: RunEvent): string | undefined { return `> run ${event.runId} started`; case 'node:started': return ` - ${event.nodeId} ...`; - case 'node:completed': - return ` ok ${event.nodeId}`; + case 'node:completed': { + // Surface each produced media handle (never bytes) on its own indented line — the plain/CI leaf of the + // cross-surface "render a produced media handle" acceptance. A text-only node yields no extra lines. + const ok = ` ok ${event.nodeId}`; + const mediaLines = collectDurableMediaHandles(event.output).map( + (m) => ` ${formatProducedMedia(m)}`, + ); + return [ok, ...mediaLines].join('\n'); + } case 'node:failed': return ` FAIL ${event.nodeId}: ${event.error.code}`; case 'human_gate:paused': diff --git a/apps/cli/src/render/tui/RunApp.tsx b/apps/cli/src/render/tui/RunApp.tsx index b31b50f7..e9916458 100644 --- a/apps/cli/src/render/tui/RunApp.tsx +++ b/apps/cli/src/render/tui/RunApp.tsx @@ -1,7 +1,14 @@ import { Box, Text } from 'ink'; import { useSyncExternalStore, type ReactElement } from 'react'; -import { formatCostUsd, formatTokens, spinnerFrame, statusColor, statusGlyph } from './format.js'; +import { + formatCostUsd, + formatProducedMedia, + formatTokens, + spinnerFrame, + statusColor, + statusGlyph, +} from './format.js'; import { colorProps, dimProps, nodeSuffix } from './projection.js'; import type { RunStore } from './run-store.js'; import { MAX_ACTIVE_TOKEN_LINES, type NodeView } from './run-view-model.js'; @@ -77,6 +84,18 @@ export function RunApp(props: Readonly<{ store: RunStore }>): ReactElement { ) : null} + {/* Produced media deliverables (handle-only, never bytes) */} + {state.producedMedia.length > 0 ? ( + + {state.producedMedia.map((media) => ( + // The content-addressed handle is unique (the view-model dedups by it) + stable — a sound React key. + + {formatProducedMedia(media)} + + ))} + + ) : null} + {/* Warnings (gap / budget / gate / timeout) */} {state.warnings.length > 0 ? ( diff --git a/apps/cli/src/render/tui/final-summary.test.ts b/apps/cli/src/render/tui/final-summary.test.ts index 34e5ad4e..9805d4ca 100644 --- a/apps/cli/src/render/tui/final-summary.test.ts +++ b/apps/cli/src/render/tui/final-summary.test.ts @@ -111,4 +111,49 @@ describe('renderFinalSummary', () => { expect(out).toContain('run ended'); expect(out.endsWith('\n')).toBe(true); }); + + it('lists produced media deliverables (handle + node attribution) under a section (2.S)', () => { + const handle = `media://sha256-${'a'.repeat(64)}`; + const state = reduceAll([ + { + type: 'node:completed', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + nodeId: 'painter', + output: { + content: [ + { + type: 'media', + mimeType: 'image/png', + source: { kind: 'handle', ref: handle }, + byteLength: 9, + }, + ], + }, + tokensUsed: { input: 0, output: 0 }, + durationMs: 5, + }, + { + type: 'run:completed', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 9, + }, + ]); + const out = renderFinalSummary(state); + expect(out).toContain('produced media:'); + expect(out).toContain(`◆ image/png ${handle} (painter)`); // handle + node attribution, never bytes + }); + + it('omits the produced-media section when a run emitted none', () => { + const state = reduceAll([ + { type: 'run:cancelled', runId: RUN, timestamp: TS, sequenceNumber: 1 }, + ]); + expect(renderFinalSummary(state)).not.toContain('produced media'); + }); }); diff --git a/apps/cli/src/render/tui/final-summary.ts b/apps/cli/src/render/tui/final-summary.ts index dc2059d8..61d35501 100644 --- a/apps/cli/src/render/tui/final-summary.ts +++ b/apps/cli/src/render/tui/final-summary.ts @@ -1,4 +1,10 @@ -import { formatCostUsd, formatDuration, formatTokens, statusGlyph } from './format.js'; +import { + formatCostUsd, + formatDuration, + formatProducedMedia, + formatTokens, + statusGlyph, +} from './format.js'; import { nodeSuffix } from './projection.js'; import type { RunViewState } from './run-view-model.js'; @@ -59,5 +65,14 @@ export function renderFinalSummary(state: RunViewState): string { lines.push(` ${statusGlyph(node.status)} ${id}${nodeSuffix(node)}`); } + // The run's media deliverables — the durable handle per produced artifact (never bytes), attributed to its + // node, so they survive in the scrollback after the live frames clear (the cross-surface handle acceptance). + if (state.producedMedia.length > 0) { + lines.push(' produced media:'); + for (const media of state.producedMedia) { + lines.push(` ${formatProducedMedia(media)} (${media.nodeId})`); + } + } + return `${lines.join('\n')}\n`; } diff --git a/apps/cli/src/render/tui/format.test.ts b/apps/cli/src/render/tui/format.test.ts index 6b612307..fccd9ee7 100644 --- a/apps/cli/src/render/tui/format.test.ts +++ b/apps/cli/src/render/tui/format.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { formatCostUsd, formatDuration, + formatProducedMedia, formatTokens, SPINNER_FRAMES, spinnerFrame, @@ -72,3 +73,12 @@ describe('statusGlyph / statusColor', () => { expect(statusColor('retrying')).toBe('yellow'); }); }); + +describe('formatProducedMedia', () => { + it('renders the durable handle + mime on one line (monochrome glyph), never inline bytes', () => { + const handle = `media://sha256-${'a'.repeat(64)}`; + expect(formatProducedMedia({ mimeType: 'image/png', handle })).toBe(`◆ image/png ${handle}`); + // Pure mimeType passthrough — a non-image modality is rendered verbatim (no per-modality special-casing). + expect(formatProducedMedia({ mimeType: 'audio/mpeg', handle })).toBe(`◆ audio/mpeg ${handle}`); + }); +}); diff --git a/apps/cli/src/render/tui/format.ts b/apps/cli/src/render/tui/format.ts index ff6e95ff..743c2f29 100644 --- a/apps/cli/src/render/tui/format.ts +++ b/apps/cli/src/render/tui/format.ts @@ -90,3 +90,17 @@ export function formatDuration(ms: number): string { export function formatTokens(tokens: { readonly input: number; readonly output: number }): string { return `↑${tokens.input} ↓${tokens.output}`; } + +/** + * Format a produced media deliverable as a one-line, secret-free reference: `◆ image/png media://sha256-…`. + * Renders the durable HANDLE (never inline bytes) — the CLI's leaf of the cross-surface "each surface renders a + * produced media handle" acceptance (2.S/D-series, ADR-0042). Takes the structural minimum so both the TUI's `ProducedMediaView` and + * the engine's `DurableMediaMeta` (the plain renderer's source) reuse one format. Node attribution is the caller's. + * The leading `◆` is a monochrome glyph, consistent with the render layer's other glyphs (no pictographic emoji). + */ +export function formatProducedMedia(media: { + readonly mimeType: string; + readonly handle: string; +}): string { + return `◆ ${media.mimeType} ${media.handle}`; +} diff --git a/apps/cli/src/render/tui/run-view-model.test.ts b/apps/cli/src/render/tui/run-view-model.test.ts index ba942908..8dcebb69 100644 --- a/apps/cli/src/render/tui/run-view-model.test.ts +++ b/apps/cli/src/render/tui/run-view-model.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { initialRunViewState, + MAX_PRODUCED_MEDIA, MAX_TOKEN_CHARS, MAX_TOOL_LINES, MAX_WARNINGS, @@ -386,6 +387,36 @@ describe('reduceRunEvent', () => { expect(paused.summary).toEqual({ outcome: 'paused', pausedGateIds: ['g1'] }); }); + it('folds run:cancelled.cumulativeCostMicrocents into the summary + running total (2.S durable fail-cost)', () => { + const s = reduceRunEvent(initialRunViewState(), { + type: 'run:cancelled', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + cumulativeCostMicrocents: 4242, + }); + // The durable terminal cost (a paid media job billed before the cancel) becomes the summary total + the + // final running total — so the cancelled-run summary shows the billed figure, not a stale live value. + expect(s.summary).toEqual({ outcome: 'cancelled', totalCostMicrocents: 4242 }); + expect(s.cumulativeCostMicrocents).toBe(4242); + }); + + it('folds node:failed.cumulativeCostMicrocents into the running total (2.S durable fail-cost)', () => { + const s = reduceRunEvent(initialRunViewState(), { + type: 'node:failed', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + nodeId: 'a', + error: { code: 'tool_failed', message: 'boom', retryable: false }, + cumulativeCostMicrocents: 777, + }); + // The node fails AND its durable cost snapshot folds into the running total (like node:completed), so a + // billed-but-failed media job's cost survives a durable reconstruction (cost:updated is streamed-only). + expect(s.nodes['a']?.status).toBe('failed'); + expect(s.cumulativeCostMicrocents).toBe(777); + }); + it('does not mutate the input state (pure reducer)', () => { const s0 = initialRunViewState(); const s1 = reduceRunEvent(s0, { @@ -725,3 +756,110 @@ describe('reduceRunEvent — previously-uncovered events + edge cases', () => { expect(s.warnings.some((w) => w.includes('out of order'))).toBe(true); }); }); + +describe('reduceRunEvent — produced media (2.S, the node:completed handle surfacing)', () => { + const HANDLE_A = `media://sha256-${'a'.repeat(64)}`; + const HANDLE_B = `media://sha256-${'b'.repeat(64)}`; + /** A durable (handle-only) media part — the shape a media-producing node's output carries post-de-inline. */ + const mediaPart = (ref: string, mimeType = 'image/png'): Record => ({ + type: 'media', + mimeType, + source: { kind: 'handle', ref }, + byteLength: 256, + }); + const completed = (nodeId: string, output: unknown, seq: number): RunEvent => ({ + type: 'node:completed', + runId: RUN, + timestamp: TS, + sequenceNumber: seq, + nodeId, + output, + tokensUsed: { input: 0, output: 0 }, + durationMs: 5, + }); + + it('surfaces a produced handle (handle + mime + node) — never inline bytes, without disturbing node fields', () => { + const s = reduceAll([completed('a', { content: [mediaPart(HANDLE_A)] }, 1)]); + expect(s.producedMedia).toEqual([{ nodeId: 'a', handle: HANDLE_A, mimeType: 'image/png' }]); + // The media branch must not clobber the node's own lifecycle patch (status / durationMs). + expect(s.nodes['a']?.status).toBe('completed'); + expect(s.nodes['a']?.durationMs).toBe(5); + }); + + it('stays empty for a text-only / null node output', () => { + expect(reduceAll([completed('a', { text: 'hi' }, 1)]).producedMedia).toEqual([]); + expect(reduceAll([completed('a', null, 1)]).producedMedia).toEqual([]); + }); + + it('stays empty for a media-shaped part that collectDurableMediaHandles skips (missing byteLength / unknown mime)', () => { + // A handle part with no Y3 `byteLength`, or a mime that maps to no modality, is NOT a recordable durable + // part (content.ts durableMediaMetaOf returns undefined) — the reducer must yield no deliverable, never a + // malformed entry. Guards the reducer's reliance on the upstream skip arms. + const noByteLength = { + type: 'media', + mimeType: 'image/png', + source: { kind: 'handle', ref: HANDLE_A }, + }; + const unknownMime = { + type: 'media', + mimeType: 'application/zip', + source: { kind: 'handle', ref: HANDLE_A }, + byteLength: 8, + }; + expect(reduceAll([completed('a', { content: [noByteLength] }, 1)]).producedMedia).toEqual([]); + expect(reduceAll([completed('a', { content: [unknownMime] }, 1)]).producedMedia).toEqual([]); + }); + + it('surfaces every distinct handle in one node output, all attributed to that node', () => { + const s = reduceAll([ + completed('a', { content: [mediaPart(HANDLE_A), mediaPart(HANDLE_B, 'audio/mpeg')] }, 1), + ]); + // Both handles, attributed to node 'a' — assert order-independently (the collector's walk order is a + // stack-walk artifact, not a contract). + expect(s.producedMedia).toHaveLength(2); + expect(s.producedMedia.every((m) => m.nodeId === 'a')).toBe(true); + expect(new Set(s.producedMedia.map((m) => m.handle))).toEqual(new Set([HANDLE_A, HANDLE_B])); + expect(s.producedMedia.find((m) => m.handle === HANDLE_B)?.mimeType).toBe('audio/mpeg'); + }); + + it('accumulates across nodes, attributing each distinct handle to its emitting node', () => { + const s = reduceAll([ + completed('a', { content: [mediaPart(HANDLE_A)] }, 1), + completed('b', { content: [mediaPart(HANDLE_B, 'audio/mpeg')] }, 2), + ]); + expect(s.producedMedia.map((m) => [m.nodeId, m.mimeType])).toEqual([ + ['a', 'image/png'], + ['b', 'audio/mpeg'], + ]); + }); + + it('dedups the same handle across nodes (one deliverable; first-seen attribution wins)', () => { + // An output node's save_to re-emits the SAME content-addressed handle its upstream producer already + // surfaced — it is ONE artifact, listed once, attributed to the node that first produced it. + const afterProducer = reduceAll([completed('producer', { content: [mediaPart(HANDLE_A)] }, 1)]); + const afterSaver = reduceRunEvent( + afterProducer, + completed('saver', { content: [mediaPart(HANDLE_A)] }, 2), + ); + expect(afterSaver.producedMedia).toEqual([ + { nodeId: 'producer', handle: HANDLE_A, mimeType: 'image/png' }, + ]); + // The pure-duplicate event re-uses the SAME producedMedia array reference (the early-return on no fresh + // handles) — no needless state churn; a regression that re-allocated an equal array would trip this. + expect(afterSaver.producedMedia).toBe(afterProducer.producedMedia); + }); + + it('bounds the deliverables list to the trailing MAX_PRODUCED_MEDIA', () => { + const handle = (i: number): string => `media://sha256-${String(i).padStart(64, '0')}`; + const events = Array.from({ length: MAX_PRODUCED_MEDIA + 5 }, (_, i) => + completed('a', { content: [mediaPart(handle(i))] }, i + 1), + ); + const s = reduceAll(events); + expect(s.producedMedia).toHaveLength(MAX_PRODUCED_MEDIA); + // The trailing entries are kept: the last-emitted handle is present, the earliest dropped. + expect(s.producedMedia[s.producedMedia.length - 1]?.handle).toBe( + handle(MAX_PRODUCED_MEDIA + 4), + ); + expect(s.producedMedia.some((m) => m.handle === handle(0))).toBe(false); + }); +}); diff --git a/apps/cli/src/render/tui/run-view-model.ts b/apps/cli/src/render/tui/run-view-model.ts index d4ab1c63..03086552 100644 --- a/apps/cli/src/render/tui/run-view-model.ts +++ b/apps/cli/src/render/tui/run-view-model.ts @@ -1,4 +1,9 @@ -import type { AgentTokenEvent, RunEvent } from '@relavium/shared'; +import { + collectDurableMediaHandles, + type AgentTokenEvent, + type NodeCompletedEvent, + type RunEvent, +} from '@relavium/shared'; /** * The pure, framework-free view model for the `ink` streaming TUI (workstream **2.E**). It reduces the @@ -29,6 +34,18 @@ export interface NodeView { readonly attempt?: number; } +/** + * A produced media DELIVERABLE surfaced on a `node:completed` (2.S/D-series, ADR-0042) — the durable + * `media://sha256-` HANDLE (never inline bytes), its MIME, and the node that first emitted it. The CLI's + * leaf of the cross-surface "each surface renders a produced media handle" acceptance. (The modality is carried + * by the MIME — `image/png` → image — so it is not stored separately.) + */ +export interface ProducedMediaView { + readonly nodeId: string; + readonly handle: string; + readonly mimeType: string; +} + export interface RunSummary { readonly outcome: 'completed' | 'failed' | 'cancelled' | 'paused'; readonly totalCostMicrocents?: number; @@ -62,6 +79,9 @@ export interface RunViewState { readonly gapDetected: boolean; /** Bounded, user-facing warnings (gap, budget, timeout, gate). */ readonly warnings: readonly string[]; + /** Produced media handles surfaced as nodes complete (2.S) — the run's media deliverables, bounded to the + * trailing {@link MAX_PRODUCED_MEDIA}. Handle-only by construction (the engine de-inlines bytes upstream). */ + readonly producedMedia: readonly ProducedMediaView[]; /** Set once the run reaches a terminal/parked event — drives the final summary panel. */ readonly summary?: RunSummary; } @@ -72,6 +92,9 @@ export const MAX_TOKEN_CHARS = 4000; export const MAX_TOOL_LINES = 8; /** Recent warnings kept for display. */ export const MAX_WARNINGS = 6; +/** Produced media deliverables kept for display — generous (a real run emits a handful) but bounded so a + * pathological media-spewing run can't grow the view state without limit; the trailing entries are kept. */ +export const MAX_PRODUCED_MEDIA = 50; /** Trailing logical lines of the active node's token stream shown in the live region (RunApp). */ export const MAX_ACTIVE_TOKEN_LINES = 6; @@ -85,6 +108,7 @@ export function initialRunViewState(): RunViewState { cumulativeCostMicrocents: 0, gapDetected: false, warnings: [], + producedMedia: [], }; } @@ -94,6 +118,25 @@ function pushBounded(arr: readonly string[], line: string, max: number): string[ return next.length > max ? next.slice(next.length - max) : next; } +/** + * Append produced media deliverables, deduped by handle across the whole run (a content-addressed + * `media://sha256-` handle is ONE artifact — an `output` node's `save_to` legitimately re-emits the same + * handle its upstream producer already surfaced, so first-seen attribution wins and it is listed once), keeping + * only the trailing {@link MAX_PRODUCED_MEDIA}. + */ +function appendProducedMedia( + current: readonly ProducedMediaView[], + added: readonly ProducedMediaView[], +): readonly ProducedMediaView[] { + const seen = new Set(current.map((m) => m.handle)); + const fresh = added.filter((m) => !seen.has(m.handle)); + if (fresh.length === 0) { + return current; // every added handle is already listed — no change + } + const next = [...current, ...fresh]; + return next.length > MAX_PRODUCED_MEDIA ? next.slice(next.length - MAX_PRODUCED_MEDIA) : next; +} + /** Append streamed token text, keeping only the trailing {@link MAX_TOKEN_CHARS}. */ function appendTokens(buffer: string, token: string): string { const next = buffer + token; @@ -201,6 +244,32 @@ function reduceAgentToken(base: RunViewState, event: AgentTokenEvent): RunViewSt }; } +/** + * The `node:completed` reduction (extracted to keep {@link reduceRunEvent}'s switch lean): mark the node + * completed, fold its cumulative-cost snapshot into the running total, and append any produced media deliverables + * (handle-only — the engine de-inlined any bytes at the emit choke point, ADR-0042; `collectDurableMediaHandles` + * walks the `unknown` output cycle-safe + deduped, so a text-only node yields none). + */ +function reduceNodeCompleted(base: RunViewState, event: NodeCompletedEvent): RunViewState { + const produced = collectDurableMediaHandles(event.output).map( + (meta): ProducedMediaView => ({ + nodeId: event.nodeId, + handle: meta.handle, + mimeType: meta.mimeType, + }), + ); + return { + ...base, + ...withNode(base, event.nodeId, { status: 'completed', durationMs: event.durationMs }), + ...(event.cumulativeCostMicrocents === undefined + ? {} + : { cumulativeCostMicrocents: event.cumulativeCostMicrocents }), + ...(produced.length === 0 + ? {} + : { producedMedia: appendProducedMedia(base.producedMedia, produced) }), + }; +} + /** * Reduce one canonical {@link RunEvent} into the next immutable {@link RunViewState}. Pure: no I/O, no * mutation of `state`. A token reduce is shallow (only the active buffer changes) so a high token rate @@ -264,13 +333,7 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta return { ...base, cumulativeCostMicrocents: event.cumulativeCostMicrocents }; case 'node:completed': - return { - ...base, - ...withNode(base, event.nodeId, { status: 'completed', durationMs: event.durationMs }), - ...(event.cumulativeCostMicrocents === undefined - ? {} - : { cumulativeCostMicrocents: event.cumulativeCostMicrocents }), - }; + return reduceNodeCompleted(base, event); case 'node:failed': return { @@ -280,6 +343,12 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta errorCode: event.error.code, ...attemptPatch(event.attemptNumber), }), + // node:failed carries the run-wide cost snapshot at this boundary (2.S/D-GC durable fail-cost) — fold it + // into the running total just like node:completed, so a billed-but-failed media job's cost survives a + // durable reconstruction (cost:updated is streamed-only). Optional (older logs omit it). + ...(event.cumulativeCostMicrocents === undefined + ? {} + : { cumulativeCostMicrocents: event.cumulativeCostMicrocents }), }; case 'node:skipped': @@ -363,7 +432,22 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta }; case 'run:cancelled': - return { ...base, summary: { outcome: 'cancelled' } }; + // run:cancelled carries the run-wide cost snapshot (2.S/D-GC — a paid media job billed before the cancel, + // ADR-0045 §5). Fold it onto the summary + the running total (mirrors run:completed) so the final cost is + // the durable terminal figure, not just whatever the last transient cost:updated left. Optional (older + // logs omit it); absent ⇒ the live `cumulativeCostMicrocents` already on `base` stands. + return { + ...base, + summary: { + outcome: 'cancelled', + ...(event.cumulativeCostMicrocents === undefined + ? {} + : { totalCostMicrocents: event.cumulativeCostMicrocents }), + }, + ...(event.cumulativeCostMicrocents === undefined + ? {} + : { cumulativeCostMicrocents: event.cumulativeCostMicrocents }), + }; case 'run:paused': return { ...base, summary: { outcome: 'paused', pausedGateIds: event.gateIds } }; diff --git a/apps/cli/src/test-support.test.ts b/apps/cli/src/test-support.test.ts index 9769b520..04c6436c 100644 --- a/apps/cli/src/test-support.test.ts +++ b/apps/cli/src/test-support.test.ts @@ -1,6 +1,30 @@ +import { createClient, createRunHistoryReader, runMigrations } from '@relavium/db'; import { describe, expect, it } from 'vitest'; -import { parseNdjson } from './test-support.js'; +import { parseNdjson, seedRun } from './test-support.js'; + +describe('seedRun', () => { + it('seeds a gateless paused run as a VALID media-job park (no run:paused zero-reason violation)', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + // No gate / budgetGateId → the media-job park branch. It must NOT throw: a `run:paused` with no suspension + // reason is rejected by RunEventSchema's union refinement, so the park seeds a parked node + media_job. + const runId = await seedRun(client.db, { slug: 'wf', runId: 'r1', state: 'paused' }); + expect(runId).toBe('r1'); + const reader = createRunHistoryReader(client.db); + expect(reader.loadRun('r1')?.status).toBe('paused'); + const paused = reader.loadRunEvents('r1').find((e) => e.type === 'run:paused'); + if (paused?.type !== 'run:paused') { + throw new Error('expected a run:paused event'); + } + expect(paused.gateIds).toEqual([]); // no human gate — a media-job park, not a gate park + expect(paused.pendingMediaJobNodeIds).toEqual(['g']); + } finally { + client.sqlite.close(); + } + }); +}); describe('parseNdjson', () => { it('parses one JSON object per non-empty line', () => { diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 2322aa68..a276b550 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -1,10 +1,50 @@ import { randomUUID } from 'node:crypto'; import { createRunHistoryStore, type Db } from '@relavium/db'; +import { CapabilityFlagsSchema, type CapabilityFlags } from '@relavium/llm'; import { RunEventSchema, type RunEvent } from '@relavium/shared'; import type { CliIo } from './process/io.js'; +/** + * A well-formed chat-surface {@link CapabilityFlags} (text-only output) — the `model_catalog.capabilities` blob + * the D15 load-check projects and re-validates. Built THROUGH `CapabilityFlagsSchema` so the drift-refine + * (`vision` mirrors `media.input.image`, ADR-0031) is enforced at module load: the single fixture both the + * media-wiring and `run` command tests project, so that invariant is encoded once, not copied per file. + */ +export const CHAT_TEXT_CAPABILITY_FLAGS: CapabilityFlags = CapabilityFlagsSchema.parse({ + tools: true, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: false, + media: { + input: { image: false, audio: false, video: false, document: false }, + outputCombinations: [['text']], + surface: 'chat', + }, +}); + +/** + * A well-formed generative-surface {@link CapabilityFlags} — routes to `generateMedia` (ADR-0045 §1), so its + * inline `outputCombinations` is empty and the load-check's generative branch keys on `media.surface`. The + * matching catalog row drives the projection's generative path (distinct from the chat inline-membership path). + */ +export const GENERATIVE_IMAGE_CAPABILITY_FLAGS: CapabilityFlags = CapabilityFlagsSchema.parse({ + tools: false, + streaming: false, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: false, + media: { + input: { image: false, audio: false, video: false, document: false }, + outputCombinations: [], + surface: 'generative', + }, +}); + /** * Test-only IO capture: a {@link CliIo} whose `writeOut`/`writeErr` accumulate into arrays, so a test * can assert on the exact stdout (NDJSON / human lines) and stderr (diagnostics) a command produced. @@ -75,9 +115,10 @@ const DEFAULT_TS_MS = 1_750_000_000_000; /** * Seed one run into the history db for the read-command tests (`list`/`logs`/`status`/`gate list`, 2.I): - * `run:started` → one node lifecycle → the requested terminal/pause state. A `paused` run can carry a pending - * human and/or budget gate. Events go through the real `persistEvent` (so `RunEventSchema` validates them and a - * malformed fixture fails loudly at seed time). Returns the run id. + * `run:started` → one node lifecycle → the requested terminal/pause state. A `paused` run carries a pending + * suspension reason: a human and/or budget gate when one is given, else an async media-job park (the only other + * valid pause — `RunEventSchema` rejects a `run:paused` with no reason). Events go through the real `persistEvent` + * (so `RunEventSchema` validates them and a malformed fixture fails loudly at seed time). Returns the run id. */ export async function seedRun(db: Db, opts: SeedRunOptions): Promise { const tsMs = opts.atMs ?? DEFAULT_TS_MS; @@ -141,9 +182,21 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise { }); } } else { - // No specific gate — a media-job-style park: just `run:paused` (no human-gate node started), so - // `state: 'paused'` is never silently a 'running' run yet doesn't seed a phantom gate step. - await emit('run:paused', { pendingGateCount: 0, gateIds: [] }); + // No human/budget gate — model an async MEDIA-JOB park (1.AG Section D, ADR-0045 §2): a generative node + // parks awaiting its job, so `run:paused` carries `pendingMediaJobNodeIds` (NOT a gate). This is the only + // valid zero-gate pause — `RunEventSchema` rejects a `run:paused` that carries no suspension reason at all, + // so the park MUST seed the parked node + its `media_job:submitted` (an empty `run:paused` is malformed). + await emit('node:started', { nodeId: 'g', nodeType: 'agent' }); + await emit('media_job:submitted', { + nodeId: 'g', + jobId: 'job-1', + provider: 'openai', + model: 'gpt-image-1', + modality: 'image', + startedAt: ts, + deadlineAt: new Date(tsMs + 60_000).toISOString(), + }); + await emit('run:paused', { pendingGateCount: 0, gateIds: [], pendingMediaJobNodeIds: ['g'] }); } } else if (opts.state === 'completed') { await emit('run:completed', { diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 043a3c33..ff5c3a0e 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -88,6 +88,7 @@ max_tokens_estimate = 4096 # per-call output-token estimate the pre-egre media_job_poll_initial_ms = 5000 # async media-job (generateMedia LRO) first-poll delay + backoff base (1.AG/ADR-0045 §7) media_job_poll_max_ms = 30000 # backoff cap: poll interval = min(initial × 2^(n-1), max), no jitter media_job_deadline_ms = 1800000 # abandon a job past this (from submit) as a retryable timeout (30 min) +media_gc_grace_days = 7 # FORWARD-DECLARED (P4/D11, ADR-0042 §4c) — grace before a zero-reference media handle's CAS bytes are reclaimed by the host media GC. NOT YET WIRED: the 2.S host GC uses a built-in 7-day default (DEFAULT_MEDIA_GC_GRACE_MS) until this key is read; see [deferred-tasks.md](../../roadmap/deferred-tasks.md). [defaults.media_cost_estimate] # per-modality media-output UNIT-COUNT default for the pre-egress media cost estimate (1.AF/D17, ADR-0044 §3) — a COUNT, not a price; the per-unit price lives in the model catalog. Used when a media-output turn declares no volume. Omit the table for text-only workflows. image = 1 # assumed images per media-output turn diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index ff6a2a43..d176e4a6 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -75,8 +75,8 @@ export type RunEvent = | `agent:tool_result` | A tool returned. | `nodeId`, `toolId`, `success`, `outputSummary` (truncated for UI), `attemptNumber?` | | `agent:file_patch_proposed` | An agent proposed a file change (**gated — no write until the user accepts**; e.g. the VS Code inline-diff review). | `nodeId`, `patches: [{ uri, unifiedDiff }]` (≥1 — an empty proposal is meaningless), `attemptNumber?` | | `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage); **includes realized media spend**, folded as a disjoint addend per [ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §3 — the per-unit `Usage.mediaUnits` axis is **not yet a field on this event**, deferred, see [deferred-tasks.md](../../roadmap/deferred-tasks.md)), `attemptNumber?` (1-based **within-chain** FallbackChain attempt — resets per node-retry re-dispatch; **distinct** from `node:*.attemptNumber`, see the [two attemptNumber families](#two-attemptnumber-families) note). **Generative-node variant (1.AG Section C, [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §5):** a `media_surface: 'generative'` agent node emits **exactly one** `cost:updated` with `inputTokens` / `outputTokens` **= 0** (no token billing — the spend rides entirely in `costMicrocents` as the per-modality media addend) and **no `attemptNumber`** (no FallbackChain on the generative path — one provider, no failover). | -| `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `selected?` (a `condition`'s chosen target ids — the authoritative branch record checkpoint/resume restores from, 1.R; **may be an empty array** when the condition routes to no branch, dimming all downstream), `attemptNumber?` (1-based **node-retry** dispatch attempt — 1.S; absent ⇒ attempt 1) | -| `node:failed` | A node failed (TERMINAL — exactly one per node; emitted when the node-retry budget is exhausted, on a fatal / `retry_on`-excluded failure, **or** when a pending retry is abandoned by a cancel or a sibling abort — see 1.S). | `nodeId`, `error: {code, message, retryable, correlationId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `correlationId` is a secret-free id joined to the internal log — ADR-0036), `attemptNumber?` (the last attempt, when a retry budget was spent — 1.S) | +| `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `selected?` (a `condition`'s chosen target ids — the authoritative branch record checkpoint/resume restores from, 1.R; **may be an empty array** when the condition routes to no branch, dimming all downstream), `attemptNumber?` (1-based **node-retry** dispatch attempt — 1.S; absent ⇒ attempt 1), `cumulativeCostMicrocents?` (the run-wide running total snapshotted at this node boundary — the durable cost source checkpoint/resume restores from, since `cost:updated` is streamed-only; the engine always populates it. `node:failed` mirrors this field, 2.S/D-GC) | +| `node:failed` | A node failed (TERMINAL — exactly one per node; emitted when the node-retry budget is exhausted, on a fatal / `retry_on`-excluded failure, **or** when a pending retry is abandoned by a cancel or a sibling abort — see 1.S). | `nodeId`, `error: {code, message, retryable, correlationId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `correlationId` is a secret-free id joined to the internal log — ADR-0036), `attemptNumber?` (the last attempt, when a retry budget was spent — 1.S), `cumulativeCostMicrocents?` (the run-wide running total snapshotted AT this node boundary — the durable fail-cost so a billed-but-failed **paid media job**'s realized spend survives the transient `cost:updated`, 2.S/D-GC [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §5; mirrors `node:completed`) | | `node:retrying` | A retryable node attempt failed and the engine will re-dispatch the whole node (1.S, [ADR-0040](../../decisions/0040-node-retry-budget-above-the-chain.md)) — **non-terminal** (the node continues; `node:failed` is the terminal). | `nodeId`, `attemptNumber` (the attempt that just failed, 1-based), `error: {code, message, retryable}` (the `NodeFailure` shape — **no** `correlationId`; that anchors the terminal failure), `delayMs` (backoff before the next attempt) | | `node:skipped` | A node was skip-propagated (never ran). | `nodeId`, `reason: 'branch_not_taken' \| 'upstream_unreachable'` (`branch_not_taken` = a `condition` routed away from it; `upstream_unreachable` = every in-edge is dead because an upstream was skipped/failed). Emitted so the event log is a **complete, replayable** record — checkpoint/resume reconstructs a skipped vertex from it ([run-plan.md](../shared-core/run-plan.md)) and a surface can render the dimmed path instead of the node silently vanishing. | | `media_job:submitted` | An async media-generation job was submitted; the engine owns its poll/checkpoint/resume/cancel loop (1.AG, [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md)) — **non-terminal** (the node parks until its `node:completed`/`node:failed`). **Durable** so a crash-resume re-attaches (re-polls the opaque `jobId`) instead of re-submitting; per-poll progress is **transient** (off this durable stream). | `nodeId`, `jobId` (Relavium-opaque — never the vendor op-name), `provider`, `model`, `modality: 'image' \| 'audio' \| 'video'`, `startedAt`, `deadlineAt` | @@ -84,8 +84,8 @@ export type RunEvent = | `human_gate:resumed` | A gate decision was applied; execution continues. | `nodeId`, `decision: 'approved' \| 'rejected' \| 'input_provided'`, `decidedBy`, `payload?` | | `run:paused` | The run is suspended on **≥1 gate AND/OR ≥1 async media job** — the multi-suspension aggregate (parallel branches may each reach a gate or a media job). `pendingGateCount` is the count of `gateIds[]` (they must agree) and both are `0`/empty for a media-only park; `pendingMediaJobNodeIds` lists nodes parked on the engine-owned `pollMediaJob` loop (1.AG Section D, [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §2). At least one suspension reason (a gate or a media job) always holds. A resume disambiguates by registry: a gate by `gateId` (a decision), a media job by `nodeId` (a re-attach). | `pendingGateCount`, `gateIds[]`, `pendingMediaJobNodeIds[]?` | | `run:completed` | The run finished. | `outputs` (a record **keyed by each terminal `output` vertex's node id**, the value being that vertex's captured output — see [run-plan.md §output capture](../shared-core/run-plan.md)), `totalTokensUsed`, `totalCostMicrocents` (integer micro-cents closing total for the whole run), `durationMs` | -| `run:failed` | The run failed. | `error: {code, message, retryable, nodeId?, correlationId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `nodeId` is the root-cause node; `correlationId` joins to the internal log — ADR-0036), `partialOutputs` | -| `run:cancelled` | The run was cancelled. | (base only) | +| `run:failed` | The run failed. | `error: {code, message, retryable, nodeId?, correlationId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `nodeId` is the root-cause node; `correlationId` joins to the internal log — ADR-0036), `partialOutputs`, `cumulativeCostMicrocents?` (the run-wide running total at failure — the durable fail-cost for a **paid media job** a sibling node's failure abandoned, whose lone estimate addend is folded just before this terminal _after_ the root-cause `node:failed` snapshot, 2.S/D-GC [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §5; mirrors `run:cancelled` and the `run:completed` counterpart `totalCostMicrocents`) | +| `run:cancelled` | The run was cancelled. | `cumulativeCostMicrocents?` (the run-wide running total at cancellation — the durable fail-cost for a **paid media job** pending at the cancel, whose lone estimate addend is folded just before this terminal, 2.S/D-GC [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §5; the `run:completed` counterpart is `totalCostMicrocents`) | ### Two attemptNumber families @@ -139,6 +139,7 @@ export interface NodeCompletedEvent extends BaseEvent { durationMs: number; selected?: string[]; // a `condition` node only: the immediate target ids it routed to (the live branches); MAY be empty when it routes to no branch (all downstream skip-propagated). The authoritative record checkpoint/resume restores `selectedTargets` from (1.R). attemptNumber?: number; // 1-based NODE-RETRY dispatch attempt (1.S); absent ⇒ attempt 1 — distinct from cost:updated.attemptNumber (see "Two attemptNumber families") + cumulativeCostMicrocents?: number; // run-wide running total at this node boundary — the durable cost source checkpoint/resume restores (cost:updated is streamed-only); engine always populates. node:failed mirrors it (2.S/D-GC) } export interface NodeSkippedEvent extends BaseEvent { diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index f488d757..2a388f6c 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -151,19 +151,21 @@ Severity is the review's verified rating. Check an item off in the PR that resol > so D12/D15/D17 are inert end-to-end until a host (CLI/desktop, 1.AH/Phase-2) wires them. Recorded here > so the roadmap is not read as "live end-to-end." None is a defect in the landed policy; each is the > deferred mechanism/wiring half. *(1.AF is ✅ Done — all PRs merged #33/#34/#35/#36, 2026-06-20; the items -> below remain, owned by 1.AH.)* +> below remain: `read_media` (D12) is deferred to **2.M** (maintainer-approved), and D15/D17/D8 + the `save_to` +> semantics to the CLI/desktop host-wiring (Phase-2/Phase-3). The D15/D17/D8 check-offs for the CLI half land +> post-merge with their PR number, per the done-after-merge convention.)* - [ ] **`read_media` host `MediaReadAccess` impl + base64 encoder (D12 mechanism)** — there is no host factory that bridges `MediaReferenceStore.describe()` + `MediaStore.readRange()` (which returns `Uint8Array`) into the `MediaReadAccess` the tool needs (whose `readRange` returns an in-flight **base64** - `MediaSource`). Until a host provides one, `read_media` cannot be invoked successfully. *(packages/db; 1.AH)* + `MediaSource`). Until a host provides one, `read_media` cannot be invoked successfully. *(packages/db; 2.M)* - [ ] **`read_media` session-scope population (D12 authz data, ADR-0044 §1)** — nothing writes `session`/`workspace` `media_references` rows (the only writer, `createMediaReferencePort`, writes `run` refs only), so `describe().allowedScopes` is always `[]` and every read denies. The input-transfer - scope-population at the node/session boundary is unimplemented. *(packages/core engine input-transfer + AgentSession; 1.AH)* + scope-population at the node/session boundary is unimplemented. *(packages/core engine input-transfer + AgentSession; 2.M)* - [ ] **`ctx.mediaRead` / `ctx.requestingScope` not wired into the dispatch context** — the AgentRunner + AgentSession build `ToolDispatchContext` without these, so `read_media` always throws - `ToolUnavailableError` in the engine path (fail-closed, no leak). *(packages/core/src/engine/{agent-runner,agent-session}.ts; 1.AH)* + `ToolUnavailableError` in the engine path (fail-closed, no leak). *(packages/core/src/engine/{agent-runner,agent-session}.ts; 2.M)* - [ ] **`validateWorkflowWithCatalog` (D15) is called by no production loader** — exported + tested, but no parse/load path invokes it, so authored `output_modalities` are not load-validated (the runtime FallbackChain pre-skip — now wired onto the request — is the only backstop). A host should call it @@ -189,6 +191,19 @@ Severity is the review's verified rating. Check an item off in the PR that resol - [ ] **`save_to` url double-fetch** — a `url`-sourced media part in a save_to output is fetched twice (the save_to de-inline + the node:completed emit de-inline; the put dedupes the bytes). Thread one de-inlined result into both paths to fetch once. *(low · packages/core/src/engine/engine.ts `#performSaveTo`)* +- [ ] **`save_to` resumer-cwd vs original-run project root (2.S)** — on a `relavium gate` resume the `save_to` + jail root is the RESUMER's cwd (`gate.ts` passes `deps.global.cwd`), not the original run's project root, so a + run started in dir A and resumed from B writes its deliverables under `B/.relavium/runs/`. The `realpath`+ + `commonpath` jail still holds (no escape) — only the destination differs. Persist the original run's project + root in the run snapshot and re-jail under it on resume for an identical location. *(low · apps/cli `gate.ts`; Phase-2)* +- [ ] **Host-GC orchestration is CLI-local (2.S)** — `runHostMediaGc` (the 3 ordered steps: clean-terminal + reclaim-retry, grace-window byte reclaim, CAS-orphan sweep + the `orphanMinAgeMs` concurrent-writer age-guard) + is host-agnostic but lives in `apps/cli/src/engine/media-gc.ts`, so the Phase-3 desktop / Phase-6 cloud hosts + can't reuse it. When a 2nd host wires media GC, promote the pure orchestration to `@relavium/db` (or a shared + host-helper) and pin the mechanism in a `docs/reference/` home so the hosts can't drift. *(med · apps/cli → @relavium/db; Phase-3+)* +- [ ] **`[defaults].media_gc_grace_days` is forward-declared, not read (2.S)** — the host GC uses the built-in + `DEFAULT_MEDIA_GC_GRACE_MS` (7 days); the config key (config-spec.md, ADR-0042 §4c) is documented but not yet + threaded through `sweepHostMediaBestEffort`'s `graceMs`. Resolve it from config and pass it through. *(low · apps/cli; Phase-2)* - [ ] **Keychain no-raw-key IPC test (ADR-0044 §4 acceptance gate)** — ADR-0044 §4 makes "the keychain bridge never returns a raw key from an IPC command" an **explicit 1.AF test deliverable**, bundled with the media IPC/byte-delivery review surface. That IPC surface is the desktop/Tauri command layer, which is **unbuilt at diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 6eb4ec11..a33ce132 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -118,15 +118,17 @@ A chat-only relaxation of any rule here is a security violation, not a feature. prompt body); see the parse-time rejection rule under [Sandbox and tool policy](#sandbox-and-tool-policy-run_command-node-tools-secret-inputs). -## Network and outbound URLs (SSRF — three egress paths today, a fourth reserved) +## Network and outbound URLs (SSRF — four egress paths, all on one primitive) -There are **three** user-supplied outbound-URL paths today (a fourth — the multimodal media `url` -carrier — is forward-looking; see the last bullet), and they share **one** vetted +There are **four** outbound-URL paths (the fourth — the multimodal media `url` carrier — is now wired +host-side via [ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)'s `fetchMediaBytes`; +see the last bullet), and they share **one** vetted SSRF range-primitive — never a second hand-rolled parser. The same validation (HTTPS only, reject non-HTTP(S) schemes and credentials-in-URL, and **block private/loopback/link-local/metadata ranges** — `127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `100.64/10` (CGNAT), `169.254/16` incl. the cloud metadata IP `169.254.169.254`, -unless the user has explicitly opted into a local endpoint) applies to all three: +unless the user has explicitly opted into a local endpoint) applies to **all four** — including the media `url` +carrier fetched by `fetchMediaBytes`: - **Provider `baseURL`.** DeepSeek (and any OpenAI-compatible provider) is reached via a user-supplied `baseURL`. Never let an agent-config URL cause the engine to call an @@ -146,11 +148,15 @@ unless the user has explicitly opted into a local endpoint) applies to all three URLs run the **same** SSRF range-primitive (no second parser). See [mcp-integration.md](../reference/shared-core/mcp-integration.md) for the MCP contract and [ADR-0029](../decisions/0029-tool-policy-hardening.md) for the rationale. -- **Media `url` carrier (multimodal — [ADR-0031](../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) A7) — a fourth path, forward-looking.** - A media `url` source (a user-supplied input URL or a provider-returned output URL) is fetched by the - **engine** (never an adapter), through this **same** range-primitive — no second parser. It ships - **feature-flag-OFF** until the one shared primitive lands. *(Not yet built; recorded here so it binds to - the same primitive when it does.)* +- **Media `url` carrier (multimodal — [ADR-0031](../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) A7 / [ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)) — a fourth path, now wired host-side.** + A media `url` source (a provider-returned output URL re-hosted to a handle, or a user-supplied input URL) is + fetched through the **host** `fetchMedia` port — `@relavium/db`'s **`fetchMediaBytes`**, the one vetted + SSRF primitive (DNS-resolve → validate-every-IP → connect-by-validated-IP → re-validate per redirect), never an + adapter and never a second parser. The CLI host wires it with **`allowPrivate: false`** (the default-deny + posture, 2.S/[ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)); the engine owns the + `maxBytes` size bound + the run `AbortSignal`. The shared primitive **has landed** (ADR-0043, tested); a + user-supplied `url` INPUT *source* (a `url` media part crossing the seam) stays **feature-flag-OFF** + (`MEDIA_URL_SOURCE_ENABLED`) until the BYOK local-endpoint opt-in lands behind a fresh ADR. - **The check and the connect must see the same address (no TOCTOU).** The primitive resolves the hostname, validates **every** resolved IP against the range-block, and then **pins the connection to a validated IP** (connect-by-validated-IP / a lookup-pinned HTTP agent). Validating one diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index ce1c6f72..9a2be8dc 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -686,10 +686,11 @@ describe('WorkflowEngine — output-node save_to (1.AF/D16, ADR-0044 §2)', () = expect(writes).toHaveLength(0); }); - it('fails with `validation` (not `internal`) when the save_to path template cannot be resolved', async () => { - // A `save_to` referencing a non-`run.id` namespace passes the relative-path schema but resolves to - // nothing at runtime (only run.id is in scope) — an InterpolationError the engine classifies as a - // validation (authoring) fault, never an engine `internal` fault. + it('classifies an unresolvable save_to template as `validation`, not `internal` (defense-in-depth)', async () => { + // 2.S rejects a non-`run.id` save_to at PARSE (the `SaveToSchema` refine; covered in shared/node.test.ts), + // so authored YAML can no longer reach this. But #performSaveTo must STILL classify an unresolvable + // template — a programmatically-built definition that bypassed the schema — as a `validation` (authoring) + // fault, never an engine `internal` fault. Build the bad save_to by replacing it AFTER parse (schema bypass). const { store: mediaStore, puts } = stubMediaStore(); const { write, writes } = stubMediaWrite(); const host = createInMemoryHost({ @@ -697,14 +698,25 @@ describe('WorkflowEngine — output-node save_to (1.AF/D16, ADR-0044 §2)', () = mediaStore, mediaWrite: write, }); - const wf = workflow(` id: saveto-badtmpl + const valid = workflow(` id: saveto-badtmpl nodes: - { id: start, type: input } - { id: gen, type: transform, transform: 'g' } - - { id: out, type: output, save_to: 'out/{{ inputs.missing }}/x.png' } + - { id: out, type: output, save_to: 'out/{{ run.id }}/x.png' } edges: - { from: start, to: gen } - { from: gen, to: out }`); + const wf: WorkflowDefinition = { + ...valid, + workflow: { + ...valid.workflow, + nodes: valid.workflow.nodes.map((n) => + n.id === 'out' && n.type === 'output' + ? { ...n, save_to: 'out/{{ inputs.missing }}/x.png' } + : n, + ), + }, + }; const events = await drain( engineWith({ out: () => ({ kind: 'completed', output: { image: MEDIA_PART } }) }, host).start( { @@ -1035,6 +1047,120 @@ describe('WorkflowEngine — the exactly-one-terminal-event invariant', () => { expect(failed.partialOutputs).not.toHaveProperty('done'); assertGapFreeSeq(events); }); + + it('snapshots the run-wide cumulative cost onto node:failed (durable fail-cost, 2.S/D-GC, ADR-0045 §5)', async () => { + const emitCost = (nodeId: string, amount: number): Handler => { + return (ctx) => { + ctx.emit({ + type: 'cost:updated', + nodeId, + model: 'm', + inputTokens: 1, + outputTokens: 1, + costMicrocents: amount, + cumulativeCostMicrocents: 0, // the engine owns the cumulative + }); + return { kind: 'completed', output: nodeId, tokensUsed: { input: 1, output: 1 } }; + }; + }; + const events = await drain( + engineWith({ + start: emitCost('start', 100), + work: () => ({ + kind: 'failed', + error: { code: 'tool_failed', message: 'boom', retryable: false }, + }), + }).start({ workflow: workflow(SEQUENTIAL) }), + ); + const nodeFailed = events.find((e) => e.type === 'node:failed'); + if (nodeFailed?.type !== 'node:failed') { + throw new Error('expected node:failed'); + } + // The cost accrued before the failure (cost:updated is transient) is durable on the terminal node event. + expect(nodeFailed.cumulativeCostMicrocents).toBe(100); + }); + + it('snapshots the run-wide cumulative cost onto run:cancelled (durable fail-cost, 2.S/D-GC, ADR-0045 §5)', async () => { + const engine = engineWith({ + emit: (ctx) => { + ctx.emit({ + type: 'cost:updated', + nodeId: 'emit', + model: 'm', + inputTokens: 1, + outputTokens: 1, + costMicrocents: 100, + cumulativeCostMicrocents: 0, + }); + return { kind: 'completed', output: 'emit', tokensUsed: { input: 1, output: 1 } }; + }, + slow: (ctx) => + new Promise((resolve) => { + const onAbort = (): void => resolve({ kind: 'completed', output: 'aborted' }); + if (ctx.signal.aborted) { + onAbort(); + return; + } + ctx.signal.addEventListener('abort', onAbort); + }), + }); + const handle = engine.start({ + workflow: workflow(` id: cancel-cost + nodes: + - { id: start, type: input } + - { id: emit, type: transform, transform: 'x' } + - { id: slow, type: transform, transform: 's' } + - { id: done, type: output } + edges: + - { from: start, to: emit } + - { from: emit, to: slow } + - { from: slow, to: done }`), + }); + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'node:started' && event.nodeId === 'slow') { + engine.cancel(handle.runId); + } + } + const cancelled = events.find((e) => e.type === 'run:cancelled'); + if (cancelled?.type !== 'run:cancelled') { + throw new Error('expected run:cancelled'); + } + // The cost accrued before the cancel (cost:updated is transient) is durable on the terminal run event. + expect(cancelled.cumulativeCostMicrocents).toBe(100); + }); + + it('snapshots the run-wide cumulative cost onto run:failed (durable fail-cost, 2.S/D-GC, ADR-0045 §5)', async () => { + const events = await drain( + engineWith({ + start: (ctx) => { + ctx.emit({ + type: 'cost:updated', + nodeId: 'start', + model: 'm', + inputTokens: 1, + outputTokens: 1, + costMicrocents: 100, + cumulativeCostMicrocents: 0, // the engine owns the cumulative + }); + return { kind: 'completed', output: 'start', tokensUsed: { input: 1, output: 1 } }; + }, + work: () => ({ + kind: 'failed', + error: { code: 'tool_failed', message: 'boom', retryable: false }, + }), + }).start({ workflow: workflow(SEQUENTIAL) }), + ); + const failed = events.find((e) => e.type === 'run:failed'); + if (failed?.type !== 'run:failed') { + throw new Error('expected run:failed'); + } + // The accrued cost (cost:updated is transient) is durable on the terminal run event, mirroring run:cancelled. + // A sibling node's abandoned media-job addend folds in the same way — emitted before this terminal in #settle + // (after the root-cause node:failed snapshot) — so run:failed never under-reports the run's realized cost. + expect(failed.cumulativeCostMicrocents).toBe(100); + }); }); // --- human gate suspend / resume -------------------------------------------------------------- diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 3b0d87c7..8056d76b 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -1265,6 +1265,10 @@ class RunExecution { // can quote it and it joins to the structured internal log. error: { ...error, correlationId: this.#host.ids.newId() }, ...(attemptNumber > 1 ? { attemptNumber } : {}), + // Snapshot the run-wide cost AT this node boundary onto the durable terminal (2.S/D-GC, ADR-0045 §5): + // a billed-but-failed PAID media job folded its realized cost into the running total via cost:updated + // (transient) — persisting it here keeps that fail-cost durable. Mirrors node:completed. + cumulativeCostMicrocents: this.#cumulativeCostMicrocents, }); } @@ -1706,9 +1710,19 @@ class RunExecution { correlationId: this.#host.ids.newId(), }, partialOutputs: this.#collectOutputs('completed'), + // Snapshot the run-wide cost onto the durable terminal (2.S/D-GC, ADR-0045 §5), mirroring run:cancelled + // below. The root-cause node's node:failed snapshotted the cumulative as of that node; a SIBLING's paid + // media job abandoned by this failure had its lone estimate addend emitted just above (#emitMediaJobCost, + // before this terminal), so the cumulative now includes it and the fail-cost is durable here (cost:updated + // is transient). The checkpoint fold reads cost only from node:completed, so this never affects resume. + cumulativeCostMicrocents: this.#cumulativeCostMicrocents, }; } else { - draft = { type, runId: this.runId }; + // run:cancelled — snapshot the run-wide cost onto the durable terminal (2.S/D-GC, ADR-0045 §5). A paid + // media job pending at the cancel had its lone estimate addend emitted just above (#emitMediaJobCost, + // before this terminal), so the cumulative now includes it and the fail-cost is durable here (cost:updated + // is transient). run:completed carries the same figure as totalCostMicrocents. + draft = { type, runId: this.runId, cumulativeCostMicrocents: this.#cumulativeCostMicrocents }; } await this.#emitDurable(draft); this.#onSettled(this.runId); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 28090f61..4c077c28 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -282,6 +282,8 @@ export type { SpawnOpts, ToolNodeConfig, ToolDispatchContext, + MediaReadAccess, + MediaHandleInfo, ToolDispatchOutcome, ToolCallPart, ToolResultPart, diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts index 903886d7..2697b8cd 100644 --- a/packages/core/src/tools/builtins.test.ts +++ b/packages/core/src/tools/builtins.test.ts @@ -412,6 +412,39 @@ describe('read_media (1.AF/D12 — scope-set authz + Range gate)', () => { expect(err).toBeInstanceOf(ToolUnavailableError); }); + it('forwards ctx.signal to describe() and readRange() (D13 cancellation threading)', async () => { + const t = tool('read_media'); + const signal = new AbortController().signal; + const seen: { describe?: unknown; readRange?: unknown } = {}; + const capturing: MediaReadAccess = { + describe: (_handle, s) => { + seen.describe = s; + return Promise.resolve({ mimeType: 'image/png', byteLength: 5, allowedScopes: [SESSION] }); + }, + readRange: (_handle, range, s) => { + seen.readRange = s; + return Promise.resolve({ kind: 'base64', data: `B${range.start}-${range.end}` }); + }, + }; + await t.dispatch( + t.parseArgs({ handle: HANDLE }), + {}, + { ...mediaCtx(SESSION, capturing), signal }, + ); + expect(seen.describe).toBe(signal); + expect(seen.readRange).toBe(signal); + + // Absent-signal branch — the optional, non-breaking forward: a ctx with no signal hands the delegate + // `undefined` (the path most likely to silently regress if the optional param were dropped). + seen.describe = 'unset'; + seen.readRange = 'unset'; + // `mediaCtx` carries no `signal` (the base ctx sets none), so this is the absent-signal context — omit the + // key rather than assign `signal: undefined` (which `exactOptionalPropertyTypes` rejects for `signal?`). + await t.dispatch(t.parseArgs({ handle: HANDLE }), {}, mediaCtx(SESSION, capturing)); + expect(seen.describe).toBeUndefined(); + expect(seen.readRange).toBeUndefined(); + }); + it('returns a HANDLE source (schema-valid, not empty base64) for a whole-handle read of a zero-byte handle', async () => { const t = tool('read_media'); let read = false; diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index dbcf0096..9ed5f118 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -488,7 +488,7 @@ const readMediaTool = defineBuiltin({ if (access === undefined || requesting === undefined) { throw new ToolUnavailableError('read_media', 'media-read'); } - const info = await access.describe(args.handle); + const info = await access.describe(args.handle, ctx.signal); if (info === undefined) { throw new ToolArgsInvalidError('read_media', ['handle'], 'read_media: unknown media handle'); } @@ -523,7 +523,7 @@ const readMediaTool = defineBuiltin({ `read_media: ${checked.reason}`, ); } - const source = await access.readRange(args.handle, checked.range); + const source = await access.readRange(args.handle, checked.range, ctx.signal); return { type: 'media', mimeType: info.mimeType, source }; }, }); diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index 9598e4fa..ff9ded6b 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -248,8 +248,8 @@ export interface MediaHandleInfo { * `MediaSource` (the host encodes — the engine-pure tool never touches raw bytes). */ export interface MediaReadAccess { - describe(handle: string): Promise; - readRange(handle: string, range: ByteRange): Promise; + describe(handle: string, signal?: AbortSignalLike): Promise; + readRange(handle: string, range: ByteRange, signal?: AbortSignalLike): Promise; } export interface ToolDispatchContext { diff --git a/packages/core/src/validate-catalog.test.ts b/packages/core/src/validate-catalog.test.ts index 4914c11b..c7c0a434 100644 --- a/packages/core/src/validate-catalog.test.ts +++ b/packages/core/src/validate-catalog.test.ts @@ -62,6 +62,27 @@ describe('validateWorkflowWithCatalog (1.AF/D15 — output_modalities load-check expect(() => validateWorkflowWithCatalog(twoMedia, catalog)).toThrow(WorkflowValidationError); }); + it('THROWS for a generative-surface model with NO authored output_modalities (it always produces one media modality) — 1.AG Section C', () => { + // The output_modalities===undefined short-circuit must NOT pre-empt the generative-surface check: a + // generative model with no declaration would route to generateMedia and fail the runtime + // singleBilledModality guard — so it must fail fast at LOAD instead. + const generativeCaps: CapabilityFlags = { + ...caps([]), + media: { ...caps([]).media, surface: 'generative' }, + }; + const noModalities = agentWorkflow(', model: gpt-image-1'); // generative model, output_modalities omitted + try { + validateWorkflowWithCatalog(noModalities, () => generativeCaps); + throw new Error('expected a WorkflowValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(WorkflowValidationError); + if (error instanceof WorkflowValidationError) { + expect(error.issues[0]?.field).toBe('node `gen`.output_modalities'); + expect(error.issues[0]?.message).toContain('none were authored'); + } + } + }); + it('throws a field-named WorkflowValidationError when the model cannot output the combination', () => { const wf = agentWorkflow(", model: m1, output_modalities: ['text', 'image']"); const catalog: WorkflowModelCatalog = () => caps([['text']]); // text-only model diff --git a/packages/core/src/validate-catalog.ts b/packages/core/src/validate-catalog.ts index 985c688a..4fb7a6e9 100644 --- a/packages/core/src/validate-catalog.ts +++ b/packages/core/src/validate-catalog.ts @@ -22,51 +22,85 @@ function isBilledModality(modality: OutputModality): modality is MediaBilledModa */ export type WorkflowModelCatalog = (modelId: string) => CapabilityFlags | undefined; +type WorkflowNode = WorkflowDefinition['workflow']['nodes'][number]; + +/** + * The generative-surface one-media-modality rule (1.AG Section C, ADR-0045 §1): a `media_surface: 'generative'` + * model (gpt-image-1, Imagen, TTS) routes to `generateMedia`, whose producible output is the generateMedia + * modality — NOT the inline `outputCombinations` (empty / chat-surface only). So the inline membership check does + * not apply, but the SAME `singleBilledModality` rule the runtime dispatch enforces (exactly one of + * image|audio|video, no text) IS checked here. A generative model ALWAYS produces exactly one media modality, so + * an OMITTED `output_modalities` is as invalid as a malformed one — both fail fast at load. Returns `undefined` + * when valid. Secret-free message (a node id + the modality set). + */ +function generativeModalityIssue( + nodeId: string, + outputModalities: readonly OutputModality[] | undefined, +): WorkflowIssue | undefined { + const declared = outputModalities ?? []; + const billed = declared.filter(isBilledModality); + if (declared.length === 1 && billed.length === 1) { + return undefined; + } + return { + field: `node \`${nodeId}\`.output_modalities`, + message: + outputModalities === undefined + ? `a media_surface 'generative' model requires output_modalities to declare exactly one media modality (image | audio | video), but none were authored` + : `a media_surface 'generative' model requires output_modalities to declare exactly one media modality (image | audio | video) with no text, got [${outputModalities.join(', ')}]`, + }; +} + +/** + * Load-check one node against the catalog, returning a {@link WorkflowIssue} or `undefined` when it is fine. A + * non-agent / model-unspecified node and an unresolvable model both DEFER (no error — see + * {@link WorkflowModelCatalog}); a generative model delegates to {@link generativeModalityIssue}; otherwise the + * authored `output_modalities` must be a member of the model's `media.outputCombinations`. + */ +function nodeCatalogIssue( + node: WorkflowNode, + catalog: WorkflowModelCatalog, +): WorkflowIssue | undefined { + if (node.type !== 'agent' || node.model === undefined) { + return undefined; // not an agent, or model-unspecified — nothing to load-check + } + const caps = catalog(node.model); + if (caps === undefined) { + return undefined; // unresolvable model — defer to the runtime FallbackChain pre-skip (never a silent drop) + } + if (caps.media.surface === 'generative') { + return generativeModalityIssue(node.id, node.output_modalities); + } + if (node.output_modalities === undefined) { + return undefined; // non-generative model, text-only node — nothing to load-check + } + if (isOutputCombinationSupported(caps.media.outputCombinations, node.output_modalities)) { + return undefined; + } + return { + field: `node \`${node.id}\`.output_modalities`, + message: `model '${node.model}' does not support the output-modality combination [${node.output_modalities.join(', ')}]`, + }; +} + /** * Engine-loader pass (1.AF/D15, ADR-0044 §2): validate every agent node's authored `output_modalities` * against its resolved model's `media.outputCombinations` membership, using the host-provided `catalog`. * Runs as a separate pass because `WorkflowSchema.superRefine` has no model catalog (this is the * `packages/core` → `packages/llm` parse-time dependency — not circular, `core` already depends on the * seam). A model absent from the catalog is **deferred** (no error — see {@link WorkflowModelCatalog}); - * an incapable model throws a field-named {@link WorkflowValidationError} listing every offending node. - * Secret-free messages (a model id + the modality set, never a payload). + * an incapable model — including a generative-surface model whose `output_modalities` is omitted or is not + * exactly one media modality — throws a field-named {@link WorkflowValidationError} listing every offending + * node. Secret-free messages (a model id + the modality set, never a payload). Per-node logic lives in + * {@link nodeCatalogIssue} so this stays a thin collect-and-throw. */ export function validateWorkflowWithCatalog( workflow: WorkflowDefinition, catalog: WorkflowModelCatalog, ): void { - const issues: WorkflowIssue[] = []; - for (const node of workflow.workflow.nodes) { - if (node.type !== 'agent' || node.model === undefined || node.output_modalities === undefined) { - continue; // not an agent, or text-only / model-unspecified — nothing to load-check - } - const caps = catalog(node.model); - if (caps === undefined) { - continue; // unresolvable model — defer to the runtime FallbackChain pre-skip (never a silent drop) - } - if (caps.media.surface === 'generative') { - // A `media_surface: 'generative'` model (gpt-image-1, Imagen, TTS) routes to `generateMedia` (1.AG - // Section C, ADR-0045 §1); its producible output is defined by the generateMedia modality, NOT by the - // inline `outputCombinations` (which is empty / chat-surface only). The inline membership check does not - // apply — but the SAME one-media-modality rule the runtime dispatch enforces (`singleBilledModality`: - // exactly one of image|audio|video, no text) IS checked here, so a malformed generative node fails fast - // at load rather than only at runtime. - const billed = node.output_modalities.filter(isBilledModality); - if (node.output_modalities.length !== 1 || billed.length !== 1) { - issues.push({ - field: `node \`${node.id}\`.output_modalities`, - message: `a media_surface 'generative' model requires output_modalities to declare exactly one media modality (image | audio | video) with no text, got [${node.output_modalities.join(', ')}]`, - }); - } - continue; // the inline outputCombinations load-check does not apply to a generative model - } - if (!isOutputCombinationSupported(caps.media.outputCombinations, node.output_modalities)) { - issues.push({ - field: `node \`${node.id}\`.output_modalities`, - message: `model '${node.model}' does not support the output-modality combination [${node.output_modalities.join(', ')}]`, - }); - } - } + const issues = workflow.workflow.nodes + .map((node) => nodeCatalogIssue(node, catalog)) + .filter((issue): issue is WorkflowIssue => issue !== undefined); if (issues.length > 0) { throw new WorkflowValidationError(issues); } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 6236ae1b..7d506d88 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -130,3 +130,15 @@ export { // jail under a scope root, symlinks off, atomic publish). A host wires it into `ExecutionHost.mediaWrite`; // the pure engine never imports it (Node `node:fs` — it depends only on the @relavium/shared `MediaWritePort`). export { createFilesystemMediaWrite, MediaWriteError } from './media-write.js'; + +// Model catalog (2.S, ADR-0045 §1 / ADR-0044 §2-3) — the host reader the media routing/load-check projections +// source from: `resolveMediaSurface` (generative-vs-chat) + the validated record the host turns into a +// `@relavium/llm` `CapabilityFlags`. `db` stays free of `@relavium/llm`/`@relavium/core` — the projection is the host's. +export { + createModelCatalogStore, + ModelCatalogCapabilitiesError, + type ModelCatalogStore, + type ModelCatalogStoreDeps, + type ModelCatalogRecord, + type ModelCatalogUpsert, +} from './model-catalog-store.js'; diff --git a/packages/db/src/media-egress.test.ts b/packages/db/src/media-egress.test.ts index cf2afbac..a46d2f96 100644 --- a/packages/db/src/media-egress.test.ts +++ b/packages/db/src/media-egress.test.ts @@ -119,6 +119,16 @@ describe('fetchMediaBytes (1.AF/D9, ADR-0043 — SSRF-validated, size-bounded me expect(calls).toHaveLength(0); }); + it('blocks a 6to4 IPv6 literal embedding a loopback IPv4 (blocked_host), never resolving or connecting', async () => { + // 2002:7f00:0001:: is the 6to4 form of 127.0.0.1 — a valid IPv6 literal, so DNS is short-circuited; the + // range-check must extract the embedded IPv4 and block it (closes the SEC-EGRESS 6to4 gap). + const { deps, calls } = fakeDeps({ hops: [{ status: 200 }] }); + await expect( + fetchMediaBytes('https://[2002:7f00:0001::]/a.png', { maxBytes: 1000 }, deps), + ).rejects.toMatchObject({ code: 'blocked_host' }); + expect(calls).toHaveLength(0); + }); + it('blocks a public hostname that RESOLVES to a private IP (blocked_host), opening no connection', async () => { const { deps, calls } = fakeDeps({ resolve: { 'rebind.example': ['10.0.0.1'] }, diff --git a/packages/db/src/media-egress.ts b/packages/db/src/media-egress.ts index e232a936..7f4bbd4b 100644 --- a/packages/db/src/media-egress.ts +++ b/packages/db/src/media-egress.ts @@ -210,10 +210,14 @@ async function performHop( const ips = await resolveValidatedIps(host, deps, allowPrivate); // Connect by the FIRST validated IP — every IP was range-checked + confirmed an IP literal above, so // pinning means the address validated is the address connected to (no re-resolve TOCTOU window). - const response = await deps.openConnection( - { url: target, hostname: host, pinnedIp: ips[0] ?? host }, - signal, - ); + const pinnedIp = ips[0]; + if (pinnedIp === undefined) { + // Unreachable: `resolveValidatedIps` throws `blocked_host` on an empty result rather than returning `[]`. + // Fail closed (never fall back to pinning the UNVALIDATED hostname) so a future return-convention change + // can't silently reopen the re-resolve window. + throw new MediaEgressError('blocked_host', 'no validated IP to pin the connection to'); + } + const response = await deps.openConnection({ url: target, hostname: host, pinnedIp }, signal); if (isRedirectStatus(response.status)) { response.dispose(); // never read a redirect body const location = response.location; @@ -302,6 +306,12 @@ export const nodeMediaEgressDeps: MediaEgressDeps = { { protocol: 'https:', hostname: request.hostname, + // The URL's port (default 443) is honored as-is — a public CDN media URL may legitimately serve + // over a non-443 HTTPS port. This is safe under the current default wiring (allowPrivate: false): + // the private/loopback/link-local IP range block (resolveValidatedIps) prevents reaching an internal + // service on ANY port, so no port allow-list is needed. If the BYOK local-endpoint allowPrivate + // opt-in is ever wired, that ADR MUST add an explicit port allow-list decision (a crafted + // https://host:22/ to a permitted-private address would otherwise be reachable). See SEC-EGRESS-3. port: parsed.port === '' ? 443 : Number(parsed.port), path: `${parsed.pathname}${parsed.search}`, method: 'GET', diff --git a/packages/db/src/media-reference-store.test.ts b/packages/db/src/media-reference-store.test.ts index 5bfe4870..549b4e70 100644 --- a/packages/db/src/media-reference-store.test.ts +++ b/packages/db/src/media-reference-store.test.ts @@ -144,4 +144,25 @@ describe('MediaReferenceStore (1.AF/D12c + D11 — media_objects/media_reference await port.reclaimRun('run-1'); expect(store.removeRunReferences('run-1')).toBe(0); // the run ref was already reclaimed by the port }); + + it('listObjectHandles returns every media_objects handle incl. soft-deleted (the GC orphan-detection set)', () => { + const h2 = `media://sha256-${'c'.repeat(64)}`; + store.recordObject({ handle: HANDLE, mimeType: 'image/png', modality: 'image', byteLength: 5 }); + store.recordObject({ handle: h2, mimeType: 'audio/mpeg', modality: 'audio', byteLength: 9 }); + expect(new Set(store.listObjectHandles())).toEqual(new Set([HANDLE, h2])); + // A GC-soft-deleted (deleted_at set) row still HAS a row — its blob is not a row-less orphan, so it stays in + // the set (both handles are unreferenced + past a 0 grace ⇒ reclaimExpired soft-deletes them). + expect(store.reclaimExpired(0)).toHaveLength(2); + expect(new Set(store.listObjectHandles())).toEqual(new Set([HANDLE, h2])); + }); + + it('runReferenceRunIds returns the distinct run-kind scope ids only (the reclaim-retry input)', () => { + store.recordObject({ handle: HANDLE, mimeType: 'image/png', modality: 'image', byteLength: 5 }); + store.addReference(HANDLE, 'run', 'run-a'); + store.addReference(HANDLE, 'run', 'run-a'); // idempotent — counted once + store.addReference(HANDLE, 'run', 'run-b'); + store.addReference(HANDLE, 'node', 'node-1'); // lifetime, NOT a run ref + store.addReference(HANDLE, 'session', 's1'); // authz, NOT a run ref + expect(new Set(store.runReferenceRunIds())).toEqual(new Set(['run-a', 'run-b'])); + }); }); diff --git a/packages/db/src/media-reference-store.ts b/packages/db/src/media-reference-store.ts index e9bc8d1e..829de17d 100644 --- a/packages/db/src/media-reference-store.ts +++ b/packages/db/src/media-reference-store.ts @@ -58,6 +58,12 @@ export interface MediaReferenceStore { describe(handle: string): MediaHandleRecord | undefined; /** D11 terminal sweep: remove a run's `run`-kind references (scoped to the run); returns the count removed. */ removeRunReferences(runId: string): number; + /** Every `media_objects.handle` (incl. soft-deleted rows) — the host GC's orphan-detection set (2.S/D-GC): + * a CAS blob whose handle is NOT here has no row at all (a crash between `put` and `recordObject`). */ + listObjectHandles(): string[]; + /** The distinct run ids that hold a `run`-kind reference — the host GC's clean-terminal reclaim-retry input + * (2.S/D-GC): re-attempt {@link removeRunReferences} for those whose run is terminal (a crashed inline sweep). */ + runReferenceRunIds(): string[]; /** * D11 grace-window GC (ADR-0042 §4 step c): soft-delete (set `deleted_at`) every LIVE object that now * has **zero** references AND whose `last_referenced_at` is older than `graceMs` before now. Returns the @@ -172,6 +178,23 @@ export function createMediaReferenceStore( return result.changes; }, + listObjectHandles(): string[] { + return db + .select({ handle: mediaObjects.handle }) + .from(mediaObjects) + .all() + .map((row) => row.handle); + }, + + runReferenceRunIds(): string[] { + return db + .selectDistinct({ scopeId: mediaReferences.scopeId }) + .from(mediaReferences) + .where(eq(mediaReferences.scopeKind, 'run')) + .all() + .map((row) => row.scopeId); + }, + reclaimExpired(graceMs: number): string[] { const cutoff = now() - graceMs; const referenced = db.select({ handle: mediaReferences.handle }).from(mediaReferences); diff --git a/packages/db/src/media-store.test.ts b/packages/db/src/media-store.test.ts index 72caf0c2..5bd1ad52 100644 --- a/packages/db/src/media-store.test.ts +++ b/packages/db/src/media-store.test.ts @@ -1,10 +1,10 @@ import { createHash } from 'node:crypto'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { MEDIA_HANDLE_PATTERN } from '@relavium/shared'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { FilesystemMediaStore, InMemoryMediaStore } from './media-store.js'; @@ -118,3 +118,48 @@ describe('MediaStore.readRange (1.AF/D13 — byte-delivery Range gate)', () => { } }); }); + +describe('FilesystemMediaStore — host GC support (2.S/D-GC: delete + listHandles)', () => { + let root: string; + let store: FilesystemMediaStore; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relavium-media-gc-')); + store = new FilesystemMediaStore(root); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it('listHandles enumerates every stored handle (with mtime); an empty or absent root yields []', async () => { + expect(await store.listHandles()).toEqual([]); // the root exists but is empty + // A store at a NEVER-created path exercises the absent-root (readdir ENOENT) branch — not just empty-dir. + const absent = new FilesystemMediaStore(join(root, 'never-created-subdir')); + expect(await absent.listHandles()).toEqual([]); + const h1 = await store.put(new Uint8Array([1])); + const h2 = await store.put(new Uint8Array([2, 3])); + const listed = await store.listHandles(); + expect(new Set(listed.map((e) => e.handle))).toEqual(new Set([h1, h2])); + expect(listed.every((e) => typeof e.mtimeMs === 'number' && e.mtimeMs > 0)).toBe(true); + }); + + it('listHandles skips strays: a non-conforming file, a non-shard dir, a root file, a subdir in a shard', async () => { + const h1 = await store.put(HELLO); + const shard = h1.slice('media://sha256-'.length, 'media://sha256-'.length + 2); + // (a) a leftover temp file in the shard dir; (b) a subdir inside the shard dir (would reconstruct a bogus + // handle if treated as a file); (c) a non-2-hex dir at the root; (d) a loose file at the root. + writeFileSync(join(root, shard, `.save.${'0'.repeat(8)}.tmp`), 'x'); + mkdirSync(join(root, shard, 'a'.repeat(62)), { recursive: true }); + mkdirSync(join(root, 'zz-not-a-shard'), { recursive: true }); + writeFileSync(join(root, 'loose-file'), 'x'); + expect((await store.listHandles()).map((e) => e.handle)).toEqual([h1]); // only the real blob + }); + + it('delete removes a blob (a later get fails) and is idempotent on a missing blob', async () => { + const handle = await store.put(HELLO); + await store.delete(handle); + await expect(store.get(handle)).rejects.toThrow(); + await expect(store.delete(handle)).resolves.toBeUndefined(); // a 2nd delete is a no-op + }); + + it('delete rejects a non-media:// handle (the digest jail) — never unlinks outside the root', async () => { + await expect(store.delete('not-a-handle')).rejects.toThrow(/handle/); + }); +}); diff --git a/packages/db/src/media-store.ts b/packages/db/src/media-store.ts index 7673aa8a..3ce71065 100644 --- a/packages/db/src/media-store.ts +++ b/packages/db/src/media-store.ts @@ -1,5 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { dirname, join, resolve, sep } from 'node:path'; import { @@ -124,6 +125,81 @@ export class FilesystemMediaStore implements MediaStore { return toBase64Source(await this.get(handle)); } + /** + * Delete a blob by handle — the host GC's byte-reclamation step (2.S/D-GC, ADR-0042 §4: a grace-expired handle + * (§4c) or a row-less-orphan handle from a crash). `digestOf` rejects a non-`media://` handle and `#pathFor` + * jails the path, so this can never unlink outside the store root. Idempotent: a missing blob (already + * reclaimed / never written) is a no-op via `rm`'s `force`. NOT on the `MediaStore` engine port — GC is a host + * concern, not an engine one. + */ + async delete(handle: string): Promise { + await rm(this.#pathFor(digestOf(handle)), { force: true }); + } + + /** + * Enumerate every well-formed `media://sha256-` handle the CAS currently holds, each with its blob's + * `mtimeMs` — the host GC's orphan-detection + age-guard input (a row-less blob, 2.S/D-GC; the `mtimeMs` lets + * the GC skip a freshly-written blob a concurrent run may not have `recordObject`'d yet). Reconstructs the + * handle from the shard dir + filename and re-validates it against {@link MEDIA_HANDLE_PATTERN}, so a stray + * `.tmp` from an interrupted publish, or any non-conforming name, is skipped; the inner loop also skips a + * non-file (a stray subdir). An absent root (the CAS was never written) yields `[]`. + */ + async listHandles(): Promise> { + let shards: Dirent[]; + try { + shards = await readdir(this.#root, { withFileTypes: true }); + } catch (err) { + // An absent CAS root (never written) yields `[]` — one async call, no `existsSync`/`readdir` window. + if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { + return []; + } + throw err; + } + // CAS layout: `//` — only a 2-hex-char shard DIR holds blobs; skip strays. + const shardDirs = shards.filter((s) => s.isDirectory() && /^[0-9a-f]{2}$/.test(s.name)); + // Enumerate + stat every shard's entries concurrently. The host GC awaits this on the CLI's terminal-run + // exit path, so a sequential shard-by-shard, entry-by-entry walk added O(shards + blobs) serial I/O to + // every run's exit latency; fanning out bounds it by the slowest single shard instead. Result order is + // irrelevant — the GC consumes the handle set unordered. A shard `readdir` / entry `stat` fault still + // propagates (Promise.all rejects on the first), preserving the no-silent-partial-listing contract. + const perShard = await Promise.all( + shardDirs.map(async (shard) => { + const shardDir = join(this.#root, shard.name); + const entries = await readdir(shardDir, { withFileTypes: true }); + const found = await Promise.all( + entries.map((entry) => this.#handleForEntry(shardDir, shard.name, entry)), + ); + return found.filter((f): f is { handle: string; mtimeMs: number } => f !== undefined); + }), + ); + return perShard.flat(); + } + + /** Reconstruct + stat one shard-dir entry into a `{handle, mtimeMs}`, or `undefined` to skip it: a non-file (a + * stray subdir), a non-conforming name (a `.tmp`), or a blob that vanished between the readdir and the stat (a + * concurrent delete — ENOENT). A real permission / IO `stat` fault propagates (never a silent partial listing). */ + async #handleForEntry( + shardDir: string, + shardName: string, + entry: Dirent, + ): Promise<{ handle: string; mtimeMs: number } | undefined> { + if (!entry.isFile()) { + return undefined; + } + const handle = `${HANDLE_PREFIX}${shardName}${entry.name}`; + if (!MEDIA_HANDLE_PATTERN.test(handle)) { + return undefined; + } + try { + return { handle, mtimeMs: (await stat(join(shardDir, entry.name))).mtimeMs }; + } catch (err) { + if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { + return undefined; + } + throw err; + } + } + /** Resolve the CAS path for a validated digest, fail-closed if it would escape the store root. */ #pathFor(digest: string): string { const full = resolve(this.#root, join(digest.slice(0, 2), digest.slice(2))); diff --git a/packages/db/src/model-catalog-store.test.ts b/packages/db/src/model-catalog-store.test.ts new file mode 100644 index 00000000..08379311 --- /dev/null +++ b/packages/db/src/model-catalog-store.test.ts @@ -0,0 +1,314 @@ +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createClient, runMigrations, type DbClient } from './client.js'; +import { + createModelCatalogStore, + ModelCatalogCapabilitiesError, + type ModelCatalogStore, +} from './model-catalog-store.js'; +import { createProviderStore, type ProviderStore } from './provider-store.js'; +import { modelCatalog } from './schema.js'; + +const TS_MS = new Date('2026-06-25T12:00:00.000Z').getTime(); + +describe('createModelCatalogStore (2.S — media routing + load-check reader)', () => { + let client: DbClient; + let store: ModelCatalogStore; + let providerStore: ProviderStore; + let providerId: string; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + let n = 0; + // Shared deps so provider rows + catalog rows mint ids from ONE increasing sequence (insertion order = + // id order), which the `asc(createdAt), asc(id)` tiebreaker test relies on. + const deps = { + uuid: () => `00000000-0000-4000-8000-${String(++n).padStart(12, '0')}`, + now: () => TS_MS, + }; + // model_catalog.provider_id is an FK into llm_providers — seed a provider first. + providerStore = createProviderStore(client.db, deps); + providerId = providerStore.upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }).id; + store = createModelCatalogStore(client.db, deps); + }); + + afterEach(() => { + client.sqlite.close(); + }); + + it('upserts a generative-surface row and reads it back (record shape + parsed capabilities)', () => { + const rec = store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + supportsVision: true, + capabilities: { media: { outputCombinations: [['image']] } }, + // Distinct per-modality rates so a swapped image/audio/video column in `fromRow` fails the test. + mediaImageCostMicrocents: 1_900_000, + mediaAudioCostMicrocents: 100, + mediaVideoCostMicrocents: 200, + }); + expect(rec.modelId).toBe('gpt-image-1'); + expect(rec.mediaSurface).toBe('generative'); + expect(rec.supportsVision).toBe(true); + expect(rec.capabilities).toEqual({ media: { outputCombinations: [['image']] } }); + expect(rec.mediaImageCostMicrocents).toBe(1_900_000); + expect(rec.mediaAudioCostMicrocents).toBe(100); + expect(rec.mediaVideoCostMicrocents).toBe(200); + // The capability flags the D15 CapabilityFlags projection consumes — pin the `fromRow` column mapping + // (defaults false / true / false, since only `supportsVision` was set on the upsert). + expect(rec.supportsToolCalling).toBe(false); + expect(rec.supportsStreaming).toBe(true); + expect(rec.supportsJsonMode).toBe(false); + expect(store.getByModelId('gpt-image-1')).toEqual(rec); + }); + + it('resolveMediaSurface routes generative vs chat, and undefined for an unknown model', () => { + store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + store.upsert({ + providerId, + modelId: 'gpt-4o', + displayName: 'GPT-4o', + contextWindowTokens: 128_000, + maxOutputTokens: 16_384, + // mediaSurface omitted ⇒ defaults to 'chat' + }); + expect(store.resolveMediaSurface('gpt-image-1')).toBe('generative'); + expect(store.resolveMediaSurface('gpt-4o')).toBe('chat'); + expect(store.resolveMediaSurface('not-in-catalog')).toBeUndefined(); + // getByModelId takes a separate code path from resolveMediaSurface — assert its miss branch too. + expect(store.getByModelId('not-in-catalog')).toBeUndefined(); + }); + + it('fromRow maps each capability flag to its own column (non-default values)', () => { + store.upsert({ + providerId, + modelId: 'gpt-4o', + displayName: 'GPT-4o', + contextWindowTokens: 128_000, + maxOutputTokens: 16_384, + }); + // Set the three flags to NON-default values directly (the upsert API intentionally exposes only + // `supportsVision`). Defaults are false/true/false, so a `fromRow` mapping that read the wrong column would + // read a different value here and fail. (Three booleans cannot make every pairwise swap detectable — two + // must share a value — but distinct-from-default catches a mapping that reads the wrong column.) + client.sqlite + .prepare( + 'UPDATE model_catalog SET supports_tool_calling = 1, supports_streaming = 0, supports_json_mode = 1 WHERE model_id = ?', + ) + .run('gpt-4o'); + const rec = store.getByModelId('gpt-4o'); + expect(rec?.supportsToolCalling).toBe(true); + expect(rec?.supportsStreaming).toBe(false); + expect(rec?.supportsJsonMode).toBe(true); + }); + + it('a NULL media rate reads back as null (cost degrades to 0 — never fabricated)', () => { + const rec = store.upsert({ + providerId, + modelId: 'imagen-3', + displayName: 'Imagen 3', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + // no media rate supplied + }); + expect(rec.mediaImageCostMicrocents).toBeNull(); + expect(rec.mediaAudioCostMicrocents).toBeNull(); + expect(rec.mediaVideoCostMicrocents).toBeNull(); + }); + + it('upsert is idempotent by (provider, model) — updates, never duplicates', () => { + const a = store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'chat', + }); + const b = store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1 (v2)', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + expect(b.mediaSurface).toBe('generative'); + expect(store.resolveMediaSurface('gpt-image-1')).toBe('generative'); + // Same logical row updated in place (one active row for the model id) — read cast-free via drizzle. + const rows = client.db + .select() + .from(modelCatalog) + .where(eq(modelCatalog.modelId, 'gpt-image-1')) + .all(); + expect(rows).toHaveLength(1); + expect(b.modelId).toBe(a.modelId); + }); + + it('the asc(id) tiebreaker — not insertion order — picks between two providers with equal createdAt', () => { + const secondProviderId = providerStore.upsert({ + name: 'azure-openai', + displayName: 'Azure OpenAI', + baseUrl: 'https://example.openai.azure.com', + }).id; + // Mint ids in DESCENDING order so insertion order and id order DIVERGE: the row inserted FIRST gets the + // HIGHER id, the row inserted SECOND gets the LOWER id. Only the `asc(id)` tiebreaker (NOT rowid/insertion + // order) then yields the asserted winner — so this test fails if the tiebreaker is dropped (a bare + // `asc(createdAt)` returns the first-inserted 'chat' row on the tie). + const descendingIds = [ + 'ffffffff-0000-4000-8000-000000000001', + 'aaaaaaaa-0000-4000-8000-000000000002', + ]; + const descStore = createModelCatalogStore(client.db, { + uuid: () => descendingIds.shift() ?? 'unexpected-extra-id', + now: () => TS_MS, + }); + // Inserted FIRST → higher id (ffff…) → 'chat'. + descStore.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'high-id, inserted first', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'chat', + }); + // Inserted SECOND → lower id (aaaa…) → 'generative'. This row must win under asc(id). + descStore.upsert({ + providerId: secondProviderId, + modelId: 'gpt-image-1', + displayName: 'low-id, inserted second', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + // Two co-existing active rows; the lower-sorting minted id wins, stably across repeated reads — i.e. the + // second-inserted row, NOT the first-inserted one a missing tiebreaker (rowid order) would return. + const both = client.db + .select() + .from(modelCatalog) + .where(eq(modelCatalog.modelId, 'gpt-image-1')) + .all(); + expect(both).toHaveLength(2); + const surfaces = [0, 1, 2].map(() => store.resolveMediaSurface('gpt-image-1')); + expect(surfaces).toEqual(['generative', 'generative', 'generative']); + expect(store.getByModelId('gpt-image-1')?.providerId).toBe(secondProviderId); + }); + + it('an upsert re-activates a previously-deactivated row (the isActive:true reactivation branch)', () => { + store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'chat', + }); + client.sqlite + .prepare('UPDATE model_catalog SET is_active = 0 WHERE model_id = ?') + .run('gpt-image-1'); + expect(store.getByModelId('gpt-image-1')).toBeUndefined(); // deactivated ⇒ unreachable + // Re-upsert through the store: `upsert` sets is_active = true, so the row becomes reachable again. + const re = store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1 (re-synced)', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + expect(re.mediaSurface).toBe('generative'); + expect(store.getByModelId('gpt-image-1')?.mediaSurface).toBe('generative'); + expect(store.resolveMediaSurface('gpt-image-1')).toBe('generative'); + }); + + it('fail-closed read-scoping: a deactivated (is_active=0) or soft-deleted row is unreachable', () => { + store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + // Deactivate the row directly — a retired generative model must NOT resolve (else a node routes onto it). + client.sqlite + .prepare('UPDATE model_catalog SET is_active = 0 WHERE model_id = ?') + .run('gpt-image-1'); + expect(store.resolveMediaSurface('gpt-image-1')).toBeUndefined(); + expect(store.getByModelId('gpt-image-1')).toBeUndefined(); + // Re-activate, then soft-delete — the deletedAt filter must also exclude it. + client.sqlite + .prepare('UPDATE model_catalog SET is_active = 1, deleted_at = ? WHERE model_id = ?') + .run(TS_MS, 'gpt-image-1'); + expect(store.resolveMediaSurface('gpt-image-1')).toBeUndefined(); + expect(store.getByModelId('gpt-image-1')).toBeUndefined(); + }); + + it('fail-closed: a tampered media_surface value degrades to the safe chat surface (never generative)', () => { + store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + mediaSurface: 'generative', + }); + // Tamper the column directly (a value the typed drizzle API cannot produce) — the read boundary must not + // trust it and must NOT route a node to the generative path on a non-member value. + client.sqlite + .prepare("UPDATE model_catalog SET media_surface = 'bogus' WHERE model_id = ?") + .run('gpt-image-1'); + expect(store.resolveMediaSurface('gpt-image-1')).toBe('chat'); + expect(store.getByModelId('gpt-image-1')?.mediaSurface).toBe('chat'); + }); + + it('fail-closed: a corrupt capabilities value aborts the read with a typed ModelCatalogCapabilitiesError', () => { + store.upsert({ + providerId, + modelId: 'gpt-image-1', + displayName: 'GPT Image 1', + contextWindowTokens: 4096, + maxOutputTokens: 4096, + }); + client.sqlite + .prepare("UPDATE model_catalog SET capabilities = '[]' WHERE model_id = ?") + .run('gpt-image-1'); + // A typed domain error (not a bare TypeError) so a caller can tell a corrupt row apart from a DB fault. + expect(() => store.getByModelId('gpt-image-1')).toThrow(ModelCatalogCapabilitiesError); + // resolveMediaSurface does not parse capabilities, so it stays usable for routing. + expect(store.resolveMediaSurface('gpt-image-1')).toBe('chat'); + // A genuinely malformed (non-JSON) value takes the distinct JSON.parse-throws branch — wrapped in the SAME + // typed error (preserving the SyntaxError as `cause`) so the caller's catch handles both corrupt shapes. + client.sqlite + .prepare("UPDATE model_catalog SET capabilities = '{' WHERE model_id = ?") + .run('gpt-image-1'); + let caught: unknown; + try { + store.getByModelId('gpt-image-1'); + } catch (err) { + caught = err; + } + if (!(caught instanceof ModelCatalogCapabilitiesError)) { + throw new Error('expected a ModelCatalogCapabilitiesError on a non-JSON capabilities column'); + } + expect(caught.cause).toBeInstanceOf(SyntaxError); + }); +}); diff --git a/packages/db/src/model-catalog-store.ts b/packages/db/src/model-catalog-store.ts new file mode 100644 index 00000000..23c95a4f --- /dev/null +++ b/packages/db/src/model-catalog-store.ts @@ -0,0 +1,235 @@ +import { MEDIA_SURFACES, type MediaSurface } from '@relavium/shared'; +import { and, asc, eq, isNull } from 'drizzle-orm'; + +import type { Db } from './client.js'; +import { modelCatalog, type ModelCatalogRow, type NewModelCatalogRow } from './schema.js'; + +/** + * Model-catalog reader (workstream **2.S**, ADR-0045 §1 / ADR-0044 §2-3) — the host source for the two + * media routing/validation projections: `AgentRunnerDeps.resolveMediaSurface` (generative-vs-chat routing) and + * the `WorkflowModelCatalog` `CapabilityFlags` load-check. Until this lands the `model_catalog` table has **no + * reader**, so every model routes inline and no generative model is reachable. + * + * `@relavium/db` depends only on `@relavium/shared`, never on `@relavium/llm` (the `CapabilityFlags` home) or + * `@relavium/core`. So this store returns a **validated row record** + the pure `MediaSurface` routing; the + * `CapabilityFlags` projection (which needs the `@relavium/llm` schema) is the **host's** job — the engine stays + * portable (CLAUDE.md rule 5) and `db` stays vendor-SDK-free. Mirrors `provider-store.ts` (the mapper is the + * single row↔domain + validation boundary; ids/timestamps are store-minted via injected deps). + */ + +/** + * One active `model_catalog` row, validated at the read boundary; the host projects it → `CapabilityFlags`. + * This is a DELIBERATE projection of the two consumers — the D15 load-check (the capability flags + the parsed + * `capabilities`) and the media-cost governor (the per-modality rates) — NOT a full row mirror: descriptive/ + * pricing columns the seam does not need (`displayName`, context/token sizes, text-token costs) are omitted by + * design. Widen this only when a documented consumer needs the field. + */ +export interface ModelCatalogRecord { + readonly modelId: string; + readonly providerId: string; + /** Media-output routing surface (validated against `MEDIA_SURFACES`; a malformed value degrades to `'chat'`). */ + readonly mediaSurface: MediaSurface; + readonly supportsToolCalling: boolean; + readonly supportsVision: boolean; + readonly supportsStreaming: boolean; + readonly supportsJsonMode: boolean; + /** The parsed `capabilities` JSON object (validated to be a JSON object here; the host validates it against + * the `@relavium/llm` `CapabilityFlagsSchema`). */ + readonly capabilities: Record; + /** Per-modality media-output rates in integer µ¢; `null` ⇒ no metered rate → cost degrades to 0 (ADR-0044 §3 H4). */ + readonly mediaImageCostMicrocents: number | null; + readonly mediaAudioCostMicrocents: number | null; + readonly mediaVideoCostMicrocents: number | null; +} + +/** + * Fields a caller supplies to seed/replace a catalog row (the store mints id + timestamps + column defaults). + * Intentionally scoped to what the generative acceptance fixture + an initial sync need; the remaining capability + * flags (`supportsToolCalling`/`supportsStreaming`/`supportsJsonMode`) fall to their column defaults until a + * provider-sync needs to set them, so they read back on {@link ModelCatalogRecord} but are not settable here yet. + */ +export interface ModelCatalogUpsert { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly contextWindowTokens: number; + readonly maxOutputTokens: number; + readonly mediaSurface?: MediaSurface; + readonly supportsVision?: boolean; + readonly capabilities?: Record; + readonly mediaImageCostMicrocents?: number | null; + readonly mediaAudioCostMicrocents?: number | null; + readonly mediaVideoCostMicrocents?: number | null; +} + +export interface ModelCatalogStoreDeps { + readonly uuid: () => string; + readonly now: () => number; +} + +export interface ModelCatalogStore { + /** The `AgentRunnerDeps.resolveMediaSurface` projection: a model's media-output surface, or `undefined` when + * the model is not in the catalog (the host then defaults to `'chat'` — the safe inline path, never generative). */ + resolveMediaSurface: (modelId: string) => MediaSurface | undefined; + /** The active catalog record for a model id (the host projects it → `CapabilityFlags` for the D15 load-check). + * THROWS a {@link ModelCatalogCapabilitiesError} on a corrupt `capabilities` row (non-object / non-JSON) — + * fail-closed; the host catches THAT type per-model (so one tampered row degrades that model, not the + * whole-catalog projection) while a genuine store/DB fault propagates. Unlike {@link resolveMediaSurface}, + * which never parses `capabilities` and so stays usable for routing. */ + getByModelId: (modelId: string) => ModelCatalogRecord | undefined; + /** Seed/replace a catalog row (by provider + model) — used by the generative acceptance fixture and a future + * provider-sync; the store mints the id + timestamps. */ + upsert: (input: ModelCatalogUpsert) => ModelCatalogRecord; +} + +/** + * Validate the stored `media_surface` against the closed `MEDIA_SURFACES` set. The column is `$type` + * but carries no DB CHECK (a SQLite `ALTER ADD` limitation, schema.ts), so a tampered/foreign value must not be + * trusted. A non-member degrades to `'chat'` — the SAFE inline surface — so a malformed value can never route a + * node to the generative `generateMedia` path (fail-closed toward the lower-capability surface). + */ +function coerceMediaSurface(value: string): MediaSurface { + return MEDIA_SURFACES.find((surface) => surface === value) ?? 'chat'; +} + +/** + * A `model_catalog.capabilities` column that is not a JSON object — invalid JSON, or valid JSON that is `null` / + * an array / a scalar. A typed DOMAIN fault (mirrors {@link MediaWriteError}/`MediaEgressError`), DISTINCT from an + * infrastructure error (a closed/locked DB connection, an IO fault). The distinction matters to a caller that + * isolates a single corrupt row: the host D15 capability projection swallows THIS to defer one model, but must + * let a genuine store fault propagate. Names a reason only — never the column bytes. + */ +export class ModelCatalogCapabilitiesError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ModelCatalogCapabilitiesError'; + } +} + +/** + * Parse a stored `capabilities` JSON-text column into a JSON object — `unknown` + a runtime shape check at the + * DB read boundary (no unsafe `as`; same `unknown` + runtime-shape-check boundary posture as `provider-store.ts`'s + * `parseStringRecord`). UNLIKE that sibling (which still throws a bare TypeError / lets `JSON.parse`'s SyntaxError + * escape), a corrupt/non-object value here aborts the read with a typed {@link ModelCatalogCapabilitiesError} — + * so a caller can isolate a corrupt row from a genuine DB fault; the host then validates the object against + * `CapabilityFlagsSchema`. + */ +function parseCapabilities(json: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (err) { + throw new ModelCatalogCapabilitiesError('model_catalog.capabilities is not valid JSON', { + cause: err, + }); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new ModelCatalogCapabilitiesError('model_catalog.capabilities is not a JSON object'); + } + return { ...parsed }; +} + +function fromRow(row: ModelCatalogRow): ModelCatalogRecord { + return { + modelId: row.modelId, + providerId: row.providerId, + mediaSurface: coerceMediaSurface(row.mediaSurface), + supportsToolCalling: row.supportsToolCalling, + supportsVision: row.supportsVision, + supportsStreaming: row.supportsStreaming, + supportsJsonMode: row.supportsJsonMode, + capabilities: parseCapabilities(row.capabilities), + mediaImageCostMicrocents: row.mediaImageCostMicrocents, + mediaAudioCostMicrocents: row.mediaAudioCostMicrocents, + mediaVideoCostMicrocents: row.mediaVideoCostMicrocents, + }; +} + +/** Wire a {@link ModelCatalogStore} over a `@relavium/db` connection. */ +export function createModelCatalogStore(db: Db, deps: ModelCatalogStoreDeps): ModelCatalogStore { + // The earliest active, non-deleted row for a (non-unique-alone) model id — `model_catalog` is unique on + // (provider, model), so a model offered by two providers yields more than one active row. `asc(createdAt)` + // with a stable `asc(id)` tiebreaker (the `run-history-store.ts` convention) keeps the resolved row — hence + // the routing surface + capability record — deterministic across reads even when two rows share a createdAt. + const activeRow = (modelId: string): ModelCatalogRow | undefined => + db + .select() + .from(modelCatalog) + .where( + and( + eq(modelCatalog.modelId, modelId), + eq(modelCatalog.isActive, true), + isNull(modelCatalog.deletedAt), + ), + ) + .orderBy(asc(modelCatalog.createdAt), asc(modelCatalog.id)) + .get(); + + const rowById = (id: string): ModelCatalogRow | undefined => + db.select().from(modelCatalog).where(eq(modelCatalog.id, id)).get(); + + const getByModelId = (modelId: string): ModelCatalogRecord | undefined => { + const row = activeRow(modelId); + return row === undefined ? undefined : fromRow(row); + }; + + return { + resolveMediaSurface: (modelId) => { + const row = activeRow(modelId); + return row === undefined ? undefined : coerceMediaSurface(row.mediaSurface); + }, + + getByModelId, + + upsert: (input) => { + const t = deps.now(); + const existing = db + .select() + .from(modelCatalog) + .where( + and( + eq(modelCatalog.providerId, input.providerId), + eq(modelCatalog.modelId, input.modelId), + isNull(modelCatalog.deletedAt), + ), + ) + .get(); + const id = existing?.id ?? deps.uuid(); + const shared = { + displayName: input.displayName, + contextWindowTokens: input.contextWindowTokens, + maxOutputTokens: input.maxOutputTokens, + mediaSurface: input.mediaSurface ?? 'chat', + supportsVision: input.supportsVision ?? false, + capabilities: JSON.stringify(input.capabilities ?? {}), + mediaImageCostMicrocents: input.mediaImageCostMicrocents ?? null, + mediaAudioCostMicrocents: input.mediaAudioCostMicrocents ?? null, + mediaVideoCostMicrocents: input.mediaVideoCostMicrocents ?? null, + // An upsert (re)activates the row: keep `isActive` in lockstep with `activeRow`'s `isActive = true` + // filter so a re-upserted, previously-deactivated row is reachable again and the returned record never + // disagrees with a subsequent `getByModelId` (which filters inactive rows out). + isActive: true, + updatedAt: t, + } satisfies Partial; + if (existing === undefined) { + const row: NewModelCatalogRow = { + id, + providerId: input.providerId, + modelId: input.modelId, + createdAt: t, + ...shared, + }; + db.insert(modelCatalog).values(row).run(); + } else { + db.update(modelCatalog).set(shared).where(eq(modelCatalog.id, id)).run(); + } + // Re-read by the exact id written (not by modelId — that would return the earliest row for a model id + // offered by multiple providers, not necessarily the one just upserted). + const row = rowById(id); + if (row === undefined) { + throw new Error(`model_catalog '${input.modelId}' not found after upsert`); // unreachable — just inserted/updated + } + return fromRow(row); + }, + }; +} diff --git a/packages/shared/src/content.test.ts b/packages/shared/src/content.test.ts index 3a76fb96..126516c8 100644 --- a/packages/shared/src/content.test.ts +++ b/packages/shared/src/content.test.ts @@ -733,6 +733,10 @@ describe('SSRF range-block (isPrivateOrLocalHost)', () => { ['64:ff9b::127.0.0.1', 'NAT64-mapped loopback'], ['64:ff9b::10.0.0.1', 'NAT64-mapped private'], ['64:ff9b::169.254.169.254', 'NAT64-mapped cloud metadata'], + // 6to4 (2002::/16, RFC 3056) embeds the IPv4 in bits 16-47 — a private/loopback embed must be re-checked. + ['2002:7f00:0001::', '6to4-embedded loopback (= 127.0.0.1)'], + ['2002:a9fe:a9fe::', '6to4-embedded cloud metadata (= 169.254.169.254)'], + ['2002:0a00:0001::', '6to4-embedded private 10/8 (= 10.0.0.1)'], ['localhost', 'hostname localhost'], ['myapp.localhost', 'hostname .localhost suffix'], ['myapp.local', 'hostname .local suffix'], @@ -761,6 +765,7 @@ describe('SSRF range-block (isPrivateOrLocalHost)', () => { ['142.250.80.46', 'public IP'], ['api.openai.com', 'public hostname'], ['2001:4860:4860::8888', 'public IPv6'], + ['2002:0808:0808::', '6to4-embedded public 8.8.8.8 — must not over-block'], ['172.15.0.1', 'just below 172.16/12 range'], ['172.32.0.1', 'just above 172.16/12 range'], ['100.63.255.255', 'just below CGNAT range'], diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index aaef27fe..af8e31e7 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -1234,8 +1234,9 @@ function parseIpv6Groups(host: string): number[] | null { /** * Range-check decoded IPv6 groups: unspecified (`::`), loopback (`::1`), link-local (`fe80::/10`), - * unique-local (`fc00::/7`), and IPv4-mapped (`::ffff:a.b.c.d`) / NAT64 (`64:ff9b::a.b.c.d`) embeddings - * which are re-checked through the IPv4 rules. + * unique-local (`fc00::/7`), and the IPv4-embedding forms — IPv4-mapped (`::ffff:a.b.c.d`), NAT64 + * (`64:ff9b::a.b.c.d`), and 6to4 (`2002:a.b.c.d::/48`, the IPv4 in bits 16-47) — which are re-checked + * through the IPv4 rules so a private/loopback IPv4 cannot tunnel past the block inside an IPv6 literal. */ function isPrivateIpv6Groups(g: number[]): boolean { if (g.every((x) => x === 0)) { @@ -1257,6 +1258,9 @@ function isPrivateIpv6Groups(g: number[]): boolean { if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { return isPrivateOrLocalHost(ipv4FromGroups(g[6] ?? 0, g[7] ?? 0)); // 64:ff9b::/96 NAT64 } + if (g[0] === 0x2002) { + return isPrivateOrLocalHost(ipv4FromGroups(g[1] ?? 0, g[2] ?? 0)); // 2002::/16 6to4 — IPv4 in bits 16-47 + } return false; } diff --git a/packages/shared/src/node.test.ts b/packages/shared/src/node.test.ts index 263df671..3a71ecec 100644 --- a/packages/shared/src/node.test.ts +++ b/packages/shared/src/node.test.ts @@ -149,6 +149,34 @@ describe('NodeSchema', () => { ).toBe(false); }); + it('save_to enforces the run.id-only interpolation restriction at parse (1.AF, ADR-0044 §2)', () => { + const parse = (save_to: string): boolean => + NodeSchema.safeParse({ id: 'o', type: 'output', save_to }).success; + // A literal (no interpolation) and a `{{ run.id }}`-only template (with/without spaces) are accepted. + expect(parse('output.png')).toBe(true); + expect(parse('out/{{run.id}}/image.png')).toBe(true); + expect(parse('{{ run.id }}/image.png')).toBe(true); + // A non-run.id reference — or a filtered / trailing-path run.id — is rejected at parse, not only at runtime. + expect(parse('{{ inputs.name }}/x.png')).toBe(false); + expect(parse('out/{{ ctx.dir }}/x.png')).toBe(false); + expect(parse('{{ run.outputs.node.path }}')).toBe(false); + expect(parse('{{ run.id | upper }}/x.png')).toBe(false); + expect(parse('{{ run.id.x }}/x.png')).toBe(false); + // A mix that includes one disallowed reference alongside run.id is still rejected. + expect(parse('{{ run.id }}/{{ inputs.x }}.png')).toBe(false); + // The strip-and-check shares no grammar with the engine lexer, so a brace-in-string reference (which the + // prior naive `{{[^}]*}}` regex wrongly ACCEPTED, since the inner `}` stopped it from matching the ref) is + // still rejected — the exact load-vs-runtime gap this guards. The adjacency case is the sharpest: the old + // regex matched the leading `{{ run.id }}`, failed to match the brace-in-string ref, and so passed. + expect(parse('{{ run.outputs["a}b"] }}/x.png')).toBe(false); + expect(parse('{{ run.id }}{{ run.outputs["x}y"] }}.png')).toBe(false); + // A degenerate / empty interpolation is rejected (it is not `{{ run.id }}`, so it survives the strip). + expect(parse('{{}}/x.png')).toBe(false); + expect(parse('{{ }}/x.png')).toBe(false); + // A bare `}}` with no opening `{{` is literal text to BOTH the schema strip and the engine lexer — accepted. + expect(parse('out/}} literal.png')).toBe(true); + }); + it('rejects a retry_on listing a non-retryable error code (ADR-0040 A.4)', () => { // `tool_denied` is fatal — retrying it just re-denies; the subset enum rejects it at parse. const bad = { diff --git a/packages/shared/src/node.ts b/packages/shared/src/node.ts index e264be76..f6de2177 100644 --- a/packages/shared/src/node.ts +++ b/packages/shared/src/node.ts @@ -45,10 +45,12 @@ export const OutputModalitiesSchema = z /** * The authored `save_to` on an `output` node (1.AF, ADR-0031/0044, A9) — a **relative** path template * the surface writes generated media bytes to (the engine carries the handle on the edge; bytes - * materialize only at the surface boundary). It may interpolate `{{ run.id }}`. Authored fail-fast: - * an absolute path or a `..` traversal segment is rejected at parse; the host write port additionally - * enforces `realpath`+`commonpath` fail-closed against a scope root (security-review.md §Media byte - * delivery). The deep path discipline is the host's; this is the authoring guard. + * materialize only at the surface boundary). It may interpolate **only `{{ run.id }}`** — never + * `inputs`/`ctx`/`run.outputs` (a filesystem path must not draw arbitrary authored data into it). + * Authored fail-fast: an absolute path, a `..` traversal segment, or a non-`run.id` interpolation is + * rejected at parse; the host write port additionally enforces `realpath`+`commonpath` fail-closed + * against a scope root (security-review.md §Media byte delivery). The deep path discipline is the + * host's; these are the authoring guards. */ export const SaveToSchema = nonEmptyString .refine((p) => !p.startsWith('/') && !p.startsWith('\\') && !/^[A-Za-z]:[\\/]/.test(p), { @@ -59,6 +61,17 @@ export const SaveToSchema = nonEmptyString }) .refine((p) => !p.split(/[\\/]/).includes('..'), { message: 'save_to must not contain a ".." path segment', + }) + .refine((p) => !p.replace(/\{\{\s*run\.id\s*\}\}/g, '').includes('{{'), { + // The run.id-only reference restriction (1.AF, ADR-0044 §2; workflow-yaml-spec.md): a save_to path + // template may interpolate ONLY `{{ run.id }}`. Strip every well-formed `{{ run.id }}` (whitespace- + // tolerant) and reject if any `{{` remains — so a non-`run.id` reference (`{{ inputs.x }}`), a filtered + // `{{ run.id | … }}`, a trailing-path `{{ run.id.x }}`, a degenerate `{{}}`, or a brace-in-string + // `{{ run.outputs["a}b"] }}` is caught at LOAD (CLI exit 2), never a mid-run surprise. This is a check, + // NOT a second interpolation parser: it shares no grammar with the engine lexer, so it cannot diverge into + // accepting a reference the engine resolves to nothing at runtime. A literal (no `{{`) save_to is allowed. + message: + 'save_to may interpolate only `{{ run.id }}` (no inputs/ctx/run.outputs in a filesystem path)', }); /** Human-gate kind. */ diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index cb79604d..5e5f66ef 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -401,6 +401,23 @@ describe('cost:updated and sequenceNumber invariants', () => { expect(CostUpdatedEventSchema.safeParse({ ...ok, attemptNumber: 0 }).success).toBe(false); }); + it('accepts an optional cumulativeCostMicrocents on node:failed / run:cancelled, rejects negative/fractional (2.S/D-GC)', () => { + // The durable fail-cost snapshot (ADR-0045 §5): both terminal carriers accept the optional running total + // (omittable for backward-compat) but pin it to non-negative integer micro-cents, like every cost field. + for (const base of [valid['node:failed'], { type: 'run:cancelled' as const, ...env }]) { + expect(RunEventSchema.safeParse({ ...base, cumulativeCostMicrocents: 4242 }).success).toBe( + true, + ); + expect(RunEventSchema.safeParse(base).success).toBe(true); // still valid when omitted + expect(RunEventSchema.safeParse({ ...base, cumulativeCostMicrocents: -1 }).success).toBe( + false, + ); + expect(RunEventSchema.safeParse({ ...base, cumulativeCostMicrocents: 1.5 }).success).toBe( + false, + ); + } + }); + it('accepts sequenceNumber 0 but rejects negative / fractional', () => { const cancelled = { type: 'run:cancelled', ...env }; expect(RunEventSchema.safeParse({ ...cancelled, sequenceNumber: 0 }).success).toBe(true); diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index 393097b8..6b2f1dc5 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -237,6 +237,12 @@ export const NodeFailedEventSchema = z.object({ // budget is exhausted. `node:failed` stays the single TERMINAL failure per node; per-attempt failures // surface as `node:retrying` (below). attemptNumber: positiveInt.optional(), + // The run-wide cost running total AT this node boundary (integer micro-cents) — the SAME counter + // cost:updated carries, snapshotted onto the durable node:failed (2.S/D-GC, ADR-0045 §5) so a billed-but- + // failed PAID media job's realized cost survives on the durable terminal (cost:updated itself is streamed, + // never persisted — it was the only carrier). Optional for backward-compat with logs persisted before this + // field existed; the engine always populates it. Mirrors node:completed.cumulativeCostMicrocents. + cumulativeCostMicrocents: nonNegativeInt.optional(), }); /** @@ -344,11 +350,24 @@ export const RunFailedEventSchema = z.object({ ...runBase, error: z.object({ ...eventErrorFields, nodeId: nonEmptyString.optional() }), // nodeId = root-cause node partialOutputs: z.record(z.string(), z.unknown()), + // The run-wide cost running total at failure (integer micro-cents). The root-cause node's node:failed snapshots + // the cumulative as of THAT node, but a SIBLING node's paid media job abandoned by the failure is still billed + // provider-side and its lone estimate addend is folded only just BEFORE this terminal (ADR-0045 §5) — after that + // node:failed was already emitted. Snapshotting the cumulative here makes that fail-cost durable (2.S/D-GC); + // cost:updated, its only other carrier, is streamed, never persisted. Optional for backward-compat; the engine + // always populates it. Mirrors run:cancelled.cumulativeCostMicrocents and run:completed.totalCostMicrocents. + cumulativeCostMicrocents: nonNegativeInt.optional(), }); export const RunCancelledEventSchema = z.object({ type: z.literal('run:cancelled'), ...runBase, + // The run-wide cost running total at cancellation (integer micro-cents). A PAID media job still pending at + // the cancel was billed provider-side (its lone estimate addend is emitted just BEFORE this terminal, + // ADR-0045 §5), so snapshotting the cumulative here makes that fail-cost durable (2.S/D-GC) — cost:updated, + // its only other carrier, is streamed, never persisted. Optional for backward-compat; the engine always + // populates it. The run-completed counterpart is run:completed.totalCostMicrocents. + cumulativeCostMicrocents: nonNegativeInt.optional(), }); export const RunPausedEventSchema = z.object({