Skip to content

Commit 99936fa

Browse files
NagyViktNagyViktclaude
authored
feat(cli): colony bridge replay subcommand (#573)
* feat(cli): promote bridge lifecycle --replay to bridge replay subcommand - Default to --dry-run; require --apply for live writes - Add --rewrite-root <from>=<to> for cross-machine path captures - Reuse packages/contracts/fixtures/colony-omx-lifecycle-v1 fixtures * chore: add changeset for bridge replay subcommand Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9cf8a95 commit 99936fa

5 files changed

Lines changed: 476 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'colonyq': minor
3+
---
4+
5+
`colony bridge replay <file.pre.json>` is now a first-class subcommand for
6+
offline debugging of captured pre-tool-use envelopes. Default is `--dry-run`
7+
(ephemeral in-memory SQLite, no side effects); pass `--apply` to write to
8+
the live store. A new `--rewrite-root <from>=<to>` flag rewrites absolute
9+
paths in the envelope before dispatch so captures from another machine can
10+
be replayed locally. Reuses the existing
11+
`packages/contracts/fixtures/colony-omx-lifecycle-v1/` fixtures and does not
12+
require the worker daemon. The shell shim at `apps/cli/bin/colony.sh`
13+
short-circuits only `bridge lifecycle` to the daemon, so `bridge replay`
14+
runs in-process automatically.

apps/cli/src/commands/bridge.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
3-
import { join } from 'node:path';
3+
import { isAbsolute, join, resolve as resolvePath } from 'node:path';
44
import { loadSettings } from '@colony/config';
55
import {
66
type IngestOmxRuntimeSummaryResult,
@@ -79,6 +79,14 @@ interface BridgeLifecycleOptions {
7979
dryRun?: boolean;
8080
}
8181

82+
interface BridgeReplayOptions {
83+
json?: boolean;
84+
ide?: string;
85+
cwd?: string;
86+
apply?: boolean;
87+
rewriteRoot?: string[];
88+
}
89+
8290
interface BridgeRuntimeSummaryOptions {
8391
json?: boolean;
8492
repoRoot?: string;
@@ -212,6 +220,67 @@ export function registerBridgeCommand(program: Command, deps: BridgeCommandDeps
212220
}
213221
});
214222

223+
bridge
224+
.command('replay <file>')
225+
.description(
226+
'Replay a saved colony-omx-lifecycle-v1 envelope from disk (dry-run by default; pass --apply to write to the live store)',
227+
)
228+
.option('--json', 'emit the routing result as JSON')
229+
.option('--ide <name>', 'IDE/agent hint used when the envelope omits one')
230+
.option('--cwd <path>', 'cwd hint used when the envelope uses relative paths')
231+
.option(
232+
'--apply',
233+
'apply the envelope against the live SQLite store; default is dry-run against an ephemeral DB',
234+
)
235+
.option(
236+
'--rewrite-root <pair>',
237+
'rewrite absolute paths in the envelope as <from>=<to> (repeatable; useful when replaying a capture from another machine)',
238+
(value: string, previous: string[] | undefined) => (previous ?? []).concat([value]),
239+
)
240+
.action(async (file: string, opts: BridgeReplayOptions) => {
241+
const inputPath = resolvePath(process.cwd(), file);
242+
const raw = (deps.readReplayFile ?? defaultReadReplayFile)(inputPath);
243+
let payload = raw.trim() ? safeJson(raw) : {};
244+
245+
const rewriteRules = parseRewriteRootPairs(opts.rewriteRoot);
246+
if (rewriteRules.length > 0) {
247+
payload = rewriteEnvelopePaths(payload, rewriteRules);
248+
}
249+
250+
const applied = opts.apply === true;
251+
if (applied && !opts.json) {
252+
process.stderr.write(`${kleur.yellow('applying to live store')}\n`);
253+
}
254+
255+
const runLifecycle =
256+
deps.runOmxLifecycleEnvelope ?? (await import('@colony/hooks')).runOmxLifecycleEnvelope;
257+
const dryRun = applied ? null : (deps.createDryRunStore ?? defaultCreateDryRunStore)();
258+
try {
259+
const result = await runLifecycle(payload, {
260+
defaultCwd: opts.cwd?.trim() || process.cwd(),
261+
...(opts.ide?.trim() ? { ide: opts.ide.trim() } : {}),
262+
...(dryRun ? { store: dryRun.store } : {}),
263+
});
264+
265+
const augmented = { ...result, replay: true, applied, input_path: inputPath };
266+
267+
if (opts.json) {
268+
process.stdout.write(`${JSON.stringify(augmented, null, 2)}\n`);
269+
} else if (result.ok) {
270+
const duplicate = result.duplicate === true ? ' duplicate=true' : '';
271+
process.stdout.write(
272+
`${kleur.green('ok')} event=${result.event_type ?? '-'} route=${result.route ?? '-'}${duplicate} replay=true applied=${applied}\n`,
273+
);
274+
} else {
275+
process.stderr.write(`${kleur.red('error')} ${result.error ?? 'lifecycle failed'}\n`);
276+
}
277+
278+
if (!result.ok) process.exitCode = 1;
279+
} finally {
280+
dryRun?.cleanup();
281+
}
282+
});
283+
215284
bridge
216285
.command('runtime-summary')
217286
.description('Receive a compact OMX runtime summary from stdin')
@@ -291,3 +360,61 @@ function defaultCreateDryRunStore(): { store: MemoryStore; cleanup: () => void }
291360
},
292361
};
293362
}
363+
364+
interface RewriteRule {
365+
from: string;
366+
to: string;
367+
}
368+
369+
function parseRewriteRootPairs(values: string[] | undefined): RewriteRule[] {
370+
if (!values || values.length === 0) return [];
371+
const rules: RewriteRule[] = [];
372+
for (const value of values) {
373+
const idx = value.indexOf('=');
374+
if (idx <= 0 || idx === value.length - 1) {
375+
process.stderr.write(
376+
`${kleur.yellow('warn')} ignoring malformed --rewrite-root pair: ${value}\n`,
377+
);
378+
continue;
379+
}
380+
const from = value.slice(0, idx);
381+
const to = value.slice(idx + 1);
382+
if (!isAbsolute(from)) {
383+
process.stderr.write(
384+
`${kleur.yellow('warn')} --rewrite-root <from> must be absolute: ${from}\n`,
385+
);
386+
continue;
387+
}
388+
rules.push({ from, to });
389+
}
390+
return rules;
391+
}
392+
393+
function rewriteEnvelopePaths(value: unknown, rules: RewriteRule[]): Record<string, unknown> {
394+
const rewritten = rewriteValue(value, rules);
395+
return rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)
396+
? (rewritten as Record<string, unknown>)
397+
: {};
398+
}
399+
400+
function rewriteValue(value: unknown, rules: RewriteRule[]): unknown {
401+
if (typeof value === 'string') return rewriteString(value, rules);
402+
if (Array.isArray(value)) return value.map((entry) => rewriteValue(entry, rules));
403+
if (value && typeof value === 'object') {
404+
const out: Record<string, unknown> = {};
405+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
406+
out[key] = rewriteValue(entry, rules);
407+
}
408+
return out;
409+
}
410+
return value;
411+
}
412+
413+
function rewriteString(value: string, rules: RewriteRule[]): string {
414+
for (const rule of rules) {
415+
if (value === rule.from) return rule.to;
416+
const prefix = rule.from.endsWith('/') ? rule.from : `${rule.from}/`;
417+
if (value.startsWith(prefix)) return `${rule.to}${value.slice(rule.from.length)}`;
418+
}
419+
return value;
420+
}

apps/cli/test/bin-shim.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,17 @@ describe('bin/colony.sh', () => {
160160
expect(result.log).toContain('lifecycle');
161161
expect(result.log).not.toContain('--json');
162162
});
163+
164+
it('passes through `bridge replay <file>` unchanged (no fast-path, Node owns it)', () => {
165+
const result = runShim(['bridge', 'replay', 'foo.pre.json'], {
166+
env: { COLONY_WORKER_PORT: freeUnusedPort() },
167+
nodeStub: stubNode,
168+
logFile: stubLog,
169+
});
170+
171+
expect(result.status).toBe(0);
172+
expect(result.log).toContain('bridge');
173+
expect(result.log).toContain('replay');
174+
expect(result.log).toContain('foo.pre.json');
175+
});
163176
});

0 commit comments

Comments
 (0)