From 5cb7dd4f59e43b0fc76bd1f39030cc35880a9565 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sat, 16 May 2026 00:59:02 +0200 Subject: [PATCH 1/2] feat(cli): promote bridge lifecycle --replay to bridge replay subcommand - Default to --dry-run; require --apply for live writes - Add --rewrite-root = for cross-machine path captures - Reuse packages/contracts/fixtures/colony-omx-lifecycle-v1 fixtures --- apps/cli/src/commands/bridge.ts | 129 ++++++++- apps/cli/test/bin-shim.test.ts | 13 + apps/cli/test/bridge-replay.test.ts | 258 ++++++++++++++++++ .../CHANGE.md | 63 +++++ 4 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 apps/cli/test/bridge-replay.test.ts create mode 100644 openspec/changes/bridge-replay-subcommand-2026-05-16/CHANGE.md diff --git a/apps/cli/src/commands/bridge.ts b/apps/cli/src/commands/bridge.ts index bcfc527..a6db1e9 100644 --- a/apps/cli/src/commands/bridge.ts +++ b/apps/cli/src/commands/bridge.ts @@ -1,6 +1,6 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { isAbsolute, join, resolve as resolvePath } from 'node:path'; import { loadSettings } from '@colony/config'; import { type IngestOmxRuntimeSummaryResult, @@ -79,6 +79,14 @@ interface BridgeLifecycleOptions { dryRun?: boolean; } +interface BridgeReplayOptions { + json?: boolean; + ide?: string; + cwd?: string; + apply?: boolean; + rewriteRoot?: string[]; +} + interface BridgeRuntimeSummaryOptions { json?: boolean; repoRoot?: string; @@ -212,6 +220,67 @@ export function registerBridgeCommand(program: Command, deps: BridgeCommandDeps } }); + bridge + .command('replay ') + .description( + 'Replay a saved colony-omx-lifecycle-v1 envelope from disk (dry-run by default; pass --apply to write to the live store)', + ) + .option('--json', 'emit the routing result as JSON') + .option('--ide ', 'IDE/agent hint used when the envelope omits one') + .option('--cwd ', 'cwd hint used when the envelope uses relative paths') + .option( + '--apply', + 'apply the envelope against the live SQLite store; default is dry-run against an ephemeral DB', + ) + .option( + '--rewrite-root ', + 'rewrite absolute paths in the envelope as = (repeatable; useful when replaying a capture from another machine)', + (value: string, previous: string[] | undefined) => (previous ?? []).concat([value]), + ) + .action(async (file: string, opts: BridgeReplayOptions) => { + const inputPath = resolvePath(process.cwd(), file); + const raw = (deps.readReplayFile ?? defaultReadReplayFile)(inputPath); + let payload = raw.trim() ? safeJson(raw) : {}; + + const rewriteRules = parseRewriteRootPairs(opts.rewriteRoot); + if (rewriteRules.length > 0) { + payload = rewriteEnvelopePaths(payload, rewriteRules); + } + + const applied = opts.apply === true; + if (applied && !opts.json) { + process.stderr.write(`${kleur.yellow('applying to live store')}\n`); + } + + const runLifecycle = + deps.runOmxLifecycleEnvelope ?? (await import('@colony/hooks')).runOmxLifecycleEnvelope; + const dryRun = applied ? null : (deps.createDryRunStore ?? defaultCreateDryRunStore)(); + try { + const result = await runLifecycle(payload, { + defaultCwd: opts.cwd?.trim() || process.cwd(), + ...(opts.ide?.trim() ? { ide: opts.ide.trim() } : {}), + ...(dryRun ? { store: dryRun.store } : {}), + }); + + const augmented = { ...result, replay: true, applied, input_path: inputPath }; + + if (opts.json) { + process.stdout.write(`${JSON.stringify(augmented, null, 2)}\n`); + } else if (result.ok) { + const duplicate = result.duplicate === true ? ' duplicate=true' : ''; + process.stdout.write( + `${kleur.green('ok')} event=${result.event_type ?? '-'} route=${result.route ?? '-'}${duplicate} replay=true applied=${applied}\n`, + ); + } else { + process.stderr.write(`${kleur.red('error')} ${result.error ?? 'lifecycle failed'}\n`); + } + + if (!result.ok) process.exitCode = 1; + } finally { + dryRun?.cleanup(); + } + }); + bridge .command('runtime-summary') .description('Receive a compact OMX runtime summary from stdin') @@ -291,3 +360,61 @@ function defaultCreateDryRunStore(): { store: MemoryStore; cleanup: () => void } }, }; } + +interface RewriteRule { + from: string; + to: string; +} + +function parseRewriteRootPairs(values: string[] | undefined): RewriteRule[] { + if (!values || values.length === 0) return []; + const rules: RewriteRule[] = []; + for (const value of values) { + const idx = value.indexOf('='); + if (idx <= 0 || idx === value.length - 1) { + process.stderr.write( + `${kleur.yellow('warn')} ignoring malformed --rewrite-root pair: ${value}\n`, + ); + continue; + } + const from = value.slice(0, idx); + const to = value.slice(idx + 1); + if (!isAbsolute(from)) { + process.stderr.write( + `${kleur.yellow('warn')} --rewrite-root must be absolute: ${from}\n`, + ); + continue; + } + rules.push({ from, to }); + } + return rules; +} + +function rewriteEnvelopePaths(value: unknown, rules: RewriteRule[]): Record { + const rewritten = rewriteValue(value, rules); + return rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten) + ? (rewritten as Record) + : {}; +} + +function rewriteValue(value: unknown, rules: RewriteRule[]): unknown { + if (typeof value === 'string') return rewriteString(value, rules); + if (Array.isArray(value)) return value.map((entry) => rewriteValue(entry, rules)); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + out[key] = rewriteValue(entry, rules); + } + return out; + } + return value; +} + +function rewriteString(value: string, rules: RewriteRule[]): string { + for (const rule of rules) { + if (value === rule.from) return rule.to; + const prefix = rule.from.endsWith('/') ? rule.from : `${rule.from}/`; + if (value.startsWith(prefix)) return `${rule.to}${value.slice(rule.from.length)}`; + } + return value; +} diff --git a/apps/cli/test/bin-shim.test.ts b/apps/cli/test/bin-shim.test.ts index e049e36..e67bb47 100644 --- a/apps/cli/test/bin-shim.test.ts +++ b/apps/cli/test/bin-shim.test.ts @@ -160,4 +160,17 @@ describe('bin/colony.sh', () => { expect(result.log).toContain('lifecycle'); expect(result.log).not.toContain('--json'); }); + + it('passes through `bridge replay ` unchanged (no fast-path, Node owns it)', () => { + const result = runShim(['bridge', 'replay', 'foo.pre.json'], { + env: { COLONY_WORKER_PORT: freeUnusedPort() }, + nodeStub: stubNode, + logFile: stubLog, + }); + + expect(result.status).toBe(0); + expect(result.log).toContain('bridge'); + expect(result.log).toContain('replay'); + expect(result.log).toContain('foo.pre.json'); + }); }); diff --git a/apps/cli/test/bridge-replay.test.ts b/apps/cli/test/bridge-replay.test.ts new file mode 100644 index 0000000..3ef73b0 --- /dev/null +++ b/apps/cli/test/bridge-replay.test.ts @@ -0,0 +1,258 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defaultSettings } from '@colony/config'; +import { MemoryStore } from '@colony/core'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Replay subcommand keeps the live SQLite untouched unless the operator opts in +// with --apply. The first three tests pin that behavior; the smoke test loads a +// real fixture through the un-mocked runOmxLifecycleEnvelope to guard against +// shape drift in the envelope parser. + +const mocks = vi.hoisted(() => ({ + loadSettings: vi.fn(() => ({ fileHeatHalfLifeMinutes: 120 })), + withStore: vi.fn(async (_settings: unknown, run: (store: unknown) => unknown) => + run({ kind: 'store' }), + ), + runOmxLifecycleEnvelope: vi.fn(), +})); + +vi.mock('@colony/config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: mocks.loadSettings, + }; +}); + +vi.mock('../src/util/store.js', () => ({ + withStore: mocks.withStore, +})); + +import { registerBridgeCommand } from '../src/commands/bridge.js'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const FIXTURE_DIR = resolvePath( + HERE, + '..', + '..', + '..', + 'packages', + 'contracts', + 'fixtures', + 'colony-omx-lifecycle-v1', +); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +describe('bridge replay ', () => { + it('defaults to dry-run: uses the injected ephemeral store and reports applied=false', async () => { + const envelope = { + event_id: 'evt_replay_dry', + event_name: 'pre_tool_use', + session_id: 'codex@replay', + agent: 'codex', + cwd: '/repo', + repo_root: '/repo', + branch: 'main', + timestamp: '2026-04-29T10:01:00.000Z', + source: 'omx', + tool_name: 'Write', + tool_input: { file_path: '/repo/foo.ts' }, + }; + const readReplayFile = vi.fn((path: string) => { + expect(path).toBe(resolvePath(process.cwd(), '/tmp/saved.pre.json')); + return JSON.stringify(envelope); + }); + const cleanup = vi.fn(); + const createDryRunStore = vi.fn(() => ({ + store: { kind: 'ephemeral-store' } as unknown as MemoryStore, + cleanup, + })); + mocks.runOmxLifecycleEnvelope.mockResolvedValue({ + ok: true, + ms: 4, + event_id: 'evt_replay_dry', + event_type: 'pre_tool_use', + route: 'pre-tool-use', + }); + const output: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + output.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + + const program = new Command(); + registerBridgeCommand(program, { + readReplayFile, + createDryRunStore, + runOmxLifecycleEnvelope: mocks.runOmxLifecycleEnvelope, + }); + + await program.parseAsync( + ['node', 'test', 'bridge', 'replay', '/tmp/saved.pre.json', '--json'], + { from: 'node' }, + ); + + expect(readReplayFile).toHaveBeenCalledTimes(1); + expect(createDryRunStore).toHaveBeenCalledTimes(1); + expect(mocks.runOmxLifecycleEnvelope).toHaveBeenCalledWith( + envelope, + expect.objectContaining({ store: { kind: 'ephemeral-store' } }), + ); + expect(cleanup).toHaveBeenCalledTimes(1); + + const parsed = JSON.parse(output.join('')); + expect(parsed).toMatchObject({ + ok: true, + event_type: 'pre_tool_use', + route: 'pre-tool-use', + replay: true, + applied: false, + input_path: resolvePath(process.cwd(), '/tmp/saved.pre.json'), + }); + }); + + it('with --apply skips the dry-run store and writes against the live store', async () => { + const envelope = { + event_id: 'evt_replay_apply', + event_name: 'task_bind', + session_id: 'codex@replay', + agent: 'codex', + cwd: '/repo', + repo_root: '/repo', + branch: 'main', + timestamp: '2026-04-29T10:01:00.000Z', + source: 'omx', + }; + const readReplayFile = vi.fn(() => JSON.stringify(envelope)); + const cleanup = vi.fn(); + const createDryRunStore = vi.fn(() => ({ + store: { kind: 'ephemeral-store' } as unknown as MemoryStore, + cleanup, + })); + mocks.runOmxLifecycleEnvelope.mockResolvedValue({ + ok: true, + ms: 2, + event_id: 'evt_replay_apply', + event_type: 'task_bind', + route: 'task_bind', + }); + const stdout: string[] = []; + const stderr: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + + const program = new Command(); + registerBridgeCommand(program, { + readReplayFile, + createDryRunStore, + runOmxLifecycleEnvelope: mocks.runOmxLifecycleEnvelope, + }); + + await program.parseAsync( + ['node', 'test', 'bridge', 'replay', '/tmp/apply.pre.json', '--apply'], + { from: 'node' }, + ); + + expect(createDryRunStore).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); + expect(mocks.runOmxLifecycleEnvelope).toHaveBeenCalledWith( + envelope, + expect.not.objectContaining({ store: expect.anything() }), + ); + expect(stderr.join('')).toContain('applying to live store'); + expect(stdout.join('')).toContain('replay=true applied=true'); + }); + + it('--rewrite-root rewrites absolute paths in the envelope before dispatch', async () => { + const envelope = { + event_id: 'evt_replay_rewrite', + event_name: 'pre_tool_use', + session_id: 'codex@replay', + agent: 'codex', + cwd: '/workspace/colony', + repo_root: '/workspace/colony', + branch: 'main', + timestamp: '2026-04-29T10:01:00.000Z', + source: 'omx', + tool_name: 'Write', + tool_input: { file_path: '/workspace/colony/packages/foo.ts' }, + }; + const readReplayFile = vi.fn(() => JSON.stringify(envelope)); + const createDryRunStore = vi.fn(() => ({ + store: { kind: 'ephemeral-store' } as unknown as MemoryStore, + cleanup: vi.fn(), + })); + mocks.runOmxLifecycleEnvelope.mockResolvedValue({ + ok: true, + ms: 1, + event_id: 'evt_replay_rewrite', + event_type: 'pre_tool_use', + route: 'pre-tool-use', + }); + + const program = new Command(); + registerBridgeCommand(program, { + readReplayFile, + createDryRunStore, + runOmxLifecycleEnvelope: mocks.runOmxLifecycleEnvelope, + }); + + await program.parseAsync( + [ + 'node', + 'test', + 'bridge', + 'replay', + '/tmp/saved.pre.json', + '--json', + '--rewrite-root', + '/workspace/colony=/tmp/repo', + ], + { from: 'node' }, + ); + + expect(mocks.runOmxLifecycleEnvelope).toHaveBeenCalledTimes(1); + const calledWith = mocks.runOmxLifecycleEnvelope.mock.calls[0]?.[0] as Record; + expect(calledWith.cwd).toBe('/tmp/repo'); + expect(calledWith.repo_root).toBe('/tmp/repo'); + expect((calledWith.tool_input as Record).file_path).toBe( + '/tmp/repo/packages/foo.ts', + ); + // Non-matching prefixes are left alone. + expect(calledWith.session_id).toBe('codex@replay'); + }); + + it('smoke: drives a real fixture through the un-mocked envelope runner with an ephemeral store', async () => { + const dir = mkdtempSync(join(tmpdir(), 'colony-bridge-replay-smoke-')); + const store = new MemoryStore({ + dbPath: join(dir, 'data.db'), + settings: defaultSettings, + }); + try { + const fixturePath = join(FIXTURE_DIR, 'codex-write.pre.json'); + const raw = readFileSync(fixturePath, 'utf8'); + const envelope = JSON.parse(raw); + const { runOmxLifecycleEnvelope } = await import('@colony/hooks'); + const result = await runOmxLifecycleEnvelope(envelope, { store }); + expect(result.ok).toBe(true); + expect(result.event_type).toBe('pre_tool_use'); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/openspec/changes/bridge-replay-subcommand-2026-05-16/CHANGE.md b/openspec/changes/bridge-replay-subcommand-2026-05-16/CHANGE.md new file mode 100644 index 0000000..bffe315 --- /dev/null +++ b/openspec/changes/bridge-replay-subcommand-2026-05-16/CHANGE.md @@ -0,0 +1,63 @@ +--- +slug: bridge-replay-subcommand-2026-05-16 +--- + +# CHANGE · bridge-replay-subcommand-2026-05-16 + +## §P proposal + +# Promote `colony bridge lifecycle --replay` to first-class `bridge replay` subcommand + +## Problem + +`colony bridge lifecycle --replay ` reads a saved `.pre.json` envelope and +silently writes it to the live SQLite store unless the operator also remembers +`--dry-run`. This is the wrong default for an offline debugging tool: replay is +something you reach for *because* you are investigating, not while normal +hooks are firing. README's v0.x roadmap line `⏳ Bridge replay tool for +offline debugging from a saved .pre.json` tracks the gap. + +## What changes + +- New `colony bridge replay ` subcommand alongside the existing + `bridge lifecycle` command. +- Default behavior is dry-run: route through an ephemeral SQLite database, do + not touch the live store. +- `--apply` opts in to writing against the live store and prints a + `applying to live store` banner to stderr (pretty mode only). +- `--rewrite-root =` rewrites absolute path prefixes in the envelope + before dispatch, so captures taken on another machine (`/workspace/colony`) + can be replayed locally (`/tmp/repo`). Repeatable. +- `--json` output extends the existing `OmxLifecycleRunResult` with + `replay: true`, `applied: `, and `input_path: ` keys. +- Pretty output adds ` replay=true applied=` to the one-line summary. +- Existing `bridge lifecycle --replay` keeps working (no removal); the new + subcommand is the recommended path. +- `apps/cli/bin/colony.sh` requires no change: its fast-path only matches + `bridge lifecycle`, so `bridge replay` falls through to Node naturally. + Pinned by a new `bin-shim.test.ts` case. + +## Impact + +- **Surfaces touched.** `apps/cli/src/commands/bridge.ts`, + `apps/cli/test/bridge-replay.test.ts` (new), + `apps/cli/test/bin-shim.test.ts`. +- **Backward compatibility.** Additive. `bridge lifecycle --replay` and + `--dry-run` flags continue to work unchanged. +- **Fixtures.** Reuses `packages/contracts/fixtures/colony-omx-lifecycle-v1/`. + No new fixtures. +- **Risk.** Low. Same write path (`runOmxLifecycleEnvelope`), same store + injection seam, same cleanup. Default flips from "writes to live store" to + "dry-run" — that is the footgun this fixes. + +## §S delta +op|target|row +-|-|- + +## §T tasks +id|status|task|cites +-|-|-|- + +## §B bugs +id|status|task|cites +-|-|-|- From 7957c89f07d1dcdc000c1082533e1d8c206261ec Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sat, 16 May 2026 01:51:00 +0200 Subject: [PATCH 2/2] chore: add changeset for bridge replay subcommand Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/bridge-replay-subcommand.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/bridge-replay-subcommand.md diff --git a/.changeset/bridge-replay-subcommand.md b/.changeset/bridge-replay-subcommand.md new file mode 100644 index 0000000..d749c64 --- /dev/null +++ b/.changeset/bridge-replay-subcommand.md @@ -0,0 +1,14 @@ +--- +'colonyq': minor +--- + +`colony bridge replay ` is now a first-class subcommand for +offline debugging of captured pre-tool-use envelopes. Default is `--dry-run` +(ephemeral in-memory SQLite, no side effects); pass `--apply` to write to +the live store. A new `--rewrite-root =` flag rewrites absolute +paths in the envelope before dispatch so captures from another machine can +be replayed locally. Reuses the existing +`packages/contracts/fixtures/colony-omx-lifecycle-v1/` fixtures and does not +require the worker daemon. The shell shim at `apps/cli/bin/colony.sh` +short-circuits only `bridge lifecycle` to the daemon, so `bridge replay` +runs in-process automatically.