mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
feat(cli): Restore-Packet vertical M2c-CLI — packet-writer atomic emission + write action impl
Per orch decision (Path B sub-chunked M2c; M2c-CLI chunk first per
qitem-20260502013530-1fad3a3d acknowledgment): land packet-writer +
restore-packet write action full impl + CLI defaults + 12 new round-trip
tests. M2c-Daemon (full-read transcript route + auth + redaction +
daemon route tests) ships separately.
Files (3 changed, 1 new, 1095+ insertions):
NEW packages/cli/src/restore-packet/packet-writer.ts (~280 lines):
- writePacket(opts) → atomically writes the v0 packet directory.
- Atomic emission: writes to mkdtemp sibling tempdir, schema-validates
the resulting restore-summary.json against RESTORE_SUMMARY_SCHEMA
(the M2a R2 const), then fs.renameSync tempdir → targetDir. On any
validation failure (in-memory pre-check OR on-disk recheck after
write) OR write error: rmSync the tempdir; targetDir is NEVER created;
caller sees the error.
- Emits 4 required files (restore-instructions.md, transcript-latest.md,
touched-files.md, restore-summary.json) per contract § 1; emits
optional transcript.md when includeFullTranscript=true and adds the
full_transcript summary key in lockstep (parity rule).
- Bounded transcript-latest at 120 messages (TRANSCRIPT_BOUND const,
matching contract § 7's v0 fixed bound).
- restore-summary.json carries all 21 contract § 2.1 required fields:
source_session_id / source_rig (derived from session_name@rig
splitting) / source_runtime / source_cwd / target_rig / target_runtime
/ target_workspace_root / default_target_repo / role_pointer /
bounded_latest_transcript / touched_files / durable_pointers (4
required arrays, all empty by default — operator-supplied via flags
in v1+) / current_work_summary / next_owner / caveats /
authority_boundaries / omitted_classes / redaction_policy_id
("openrig-v0" const) / source_trust_ranking / generator_version /
generated_at (ISO-8601 now).
EDIT packages/cli/src/commands/restore-packet.ts:
- Replaced the M2a-deferred error stub in the write action with full
implementation:
- Mutual-exclusion + missing-source checks preserved from M2a.
- --source-jsonl branch: readFileSync → runtime-detect (or explicit
--source-runtime override) → parseCodexJsonl/parseClaudeTranscript →
writePacket.
- --source-session branch: getDaemonStatus + getDaemonUrl + DaemonClient
GET to /api/transcripts/<session>/full (the route M2c-Daemon will
land); same parser dispatch + writePacket pipeline.
- --source-runtime explicit value validated against codex/claude-code
enum; auto-detect when omitted; explicit error when ambiguous.
- 7 new CLI flags for the contract-required fields not derivable from
parser output: --target-rig, --target-runtime, --target-workspace-root,
--default-target-repo, --role-pointer, --current-work-summary,
--next-owner, --caveat (repeatable), --authority-boundaries,
--source-trust-ranking, --generator-version. Sensible defaults for
each so the minimum command shape is `rig restore-packet write
--source-jsonl <path> --target <dir>`.
- Per Quality Lesson v9: log only metadata (target dir, file list,
message count, omitted-class counts). Transcript content NEVER
written to stdout/stderr.
- read + validate subcommand stubs unchanged (M3-pending).
EXTEND packages/cli/test/restore-packet.test.ts (+12 tests; 63 → 75):
- 6 packet-writer atomic emission tests:
- 4 required files + summary schema-valid
- transcript.md present when includeFullTranscript=true; full_transcript
key in summary
- all 21 contract § 2.1 required fields populated from operator opts
- reject when target dir already exists (preserves marker.txt)
- atomic emission: schema-validation failure mid-write → no target dir
created AND tempdir cleaned up
- transcript-latest bounded at 120; message_count reflects actual
count (150-message input → message_count=120)
- 4 Velocity-shape round-trips (driver/guard/planner/tester roles):
synthetic Codex JSONL fixtures emulating Velocity shape (session_meta
+ tool_call + reasoning + 2 messages with redaction needle); each role
→ parser → packet-writer → schema-valid; provenance preserved at
source_session_id, source_cwd, bounded_latest_transcript.message_count;
omitted_classes contains reasoning_records + function_call_output +
redacted_secrets; transcript.md does NOT contain "sk-FakeAbCdEfGhIjKlMn"
(redaction applied end-to-end).
- 1 mock-daemon --source-session round-trip (compact-plan.test.ts:166-176
pattern): mock daemon-lifecycle module-level; mock client serves
/api/transcripts/<session>/full with synthetic JSONL; CLI write
command produces packet; mutation methods (post/delete/postText/
postExpectText) NOT called.
- 1 M2b R2 nested Claude content round-trip: full assistant tool_use +
user tool_result fixture → packet emitted → omitted_classes contains
function_call_output + raw_tool_outputs; touched_files.top_paths
includes the path from the omitted tool_result content.
TDD discipline (per memory feedback_tdd_scope): all M2c-CLI tests
authored to fail FIRST. Test commit: 11 fail (modules don't exist), 1
pass (the writer's "transcript-latest bounded" test was passing on
parser data alone). Then packet-writer + write action implemented; all
75 pass.
Re-verification gates (per dispatch):
- npx vitest run test/restore-packet.test.ts → 75/75 pass.
- npx tsc --noEmit → exit 0.
- npm run build → exit 0; dist/restore-packet/packet-writer.js +
dist/schemas/restore-summary.schema.json present.
- Compiled-output check: node -e 'import(.../packet-writer.js).then(...)'
→ writePacket: function. (Schema-validator embedded const pattern from
M2a R2 means the validator works after raw tsc emit; packet-writer
uses it directly.)
- Full CLI suite: 715/719 pass. M2b R2 baseline 703/707 + 12 new M2c-CLI
tests = 715/719. Same 4 named environmental flakes (chatroom 10s
timing, config-store env pollution, preflight x2 host port 7433); none
touch M2c-CLI files. NO regression.
Boundary discipline (per orch sub-chunking decision):
- M2c-Daemon surfaces NOT touched (packages/daemon/src/routes/transcripts.ts
unchanged; packages/daemon/test/transcript-routes.test.ts unchanged).
- The /api/transcripts/<session>/full route path is a DRAFT in this
commit; M2c-Daemon owns the actual route implementation + auth
primitive identification. If M2c-Daemon picks a different path, the
same commit can adjust this CLI line.
- Velocity prior-art .mjs FROZEN reference UNCHANGED.
- restore-packet read + validate stay M3-pending stubs.
- M3/M4/M5/M6 surfaces NOT touched.
- No daemon code changes.
- No unrelated dirty work staged.
Quality lessons applied:
- v3 distinct test summary prefixes.
- v4 marshal-layer wire-shape: tests assert each contract § 2.1
required field populated correctly + bounded_latest_transcript.bound
const 120 + redaction_policy_id "openrig-v0".
- v5 test-naming-vs-instantiation: tests invoke writePacket and
restorePacketCommand directly; mock-daemon test exercises real CLI
command parsing + real CLI dispatch through a mocked DaemonClient.
- v6 boundary isolation: M2c-CLI stays in packages/cli/src/restore-packet/
+ packages/cli/src/commands/restore-packet.ts. Daemon code untouched.
- v7 refined: M2c-CLI does NOT extend M2b R2 grant; cites the M2c
sub-chunking decision in this commit message.
- v8 honest baseline: full CLI suite re-run; named flakes baseline-checked.
- v9 synthetic tokens only: all redaction fixtures use synthetic patterns
(sk-FakeAbCdEf..., etc.); no real provider tokens in fixtures or
commits. CLI logs metadata only — never transcript content.
- v10 schema-packaging gap: packet-writer uses validateRestoreSummary
which embeds the schema as a TS const (M2a R2 fix); compiled-output
runtime check confirms reachability from dist.
- v11 (from M2b R2): nested-content-part counter for Claude parser is
exercised end-to-end via the M2b R2 round-trip test which round-trips
the guard reproducer fixture through packet-writer and asserts the
resulting summary has function_call_output and raw_tool_outputs in
omitted_classes.
Posture: full QA/testing window (per dispatch).
Source qitem: qitem-20260502013415-e9dab505 (M2c dispatch).
Sub-chunking decision: orch picked Path B (split M2c → M2c-CLI + M2c-Daemon)
in reply on qitem-20260502013530-1fad3a3d.
This commit is contained in:
1 parent
5c87145e54
commit
e5de3abd12
3 files changed
+890
-24
No files matched your search
@@ -1,17 +1,25 @@
|
||||
// restore-packet.ts — `rig restore-packet {write,read,validate}` CLI command.
|
||||
//
|
||||
// M2a chunk: command shape + mutual-exclusion for `write`; `read` and
|
||||
// `validate` are subcommand stubs that return a clear "M3 only" error so
|
||||
// the CLI surface is registrable now.
|
||||
//
|
||||
// M2b will add: source-adapter wiring (codex-jsonl-parser, claude-transcript-
|
||||
// parser, runtime-detect, redaction, omitted-records).
|
||||
// M2c will add: packet-writer atomic emission + daemon route integration.
|
||||
// M3 will replace the read/validate stubs with real implementations.
|
||||
// M2a: command shape + mutual-exclusion + read/validate stubs.
|
||||
// M2b: source-adapter wiring (codex-jsonl-parser, claude-transcript-parser,
|
||||
// runtime-detect, redaction, omitted-records).
|
||||
// M2c-CLI: write action full impl using packet-writer.ts; --source-jsonl
|
||||
// + --source-session adapter wiring; CLI flag defaults for the
|
||||
// ~11 contract-required fields not derivable from the parser.
|
||||
// M2c-Daemon: full-read transcript route + auth + redaction + tests.
|
||||
// M3: real read + validate implementations replacing the stubs.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { detectRuntime } from "../restore-packet/runtime-detect.js";
|
||||
import { parseCodexJsonl } from "../restore-packet/codex-jsonl-parser.js";
|
||||
import { parseClaudeTranscript } from "../restore-packet/claude-transcript-parser.js";
|
||||
import { writePacket, type WritePacketOptions } from "../restore-packet/packet-writer.js";
|
||||
import type { SourceRuntime, StructuredTranscript } from "../restore-packet/types.js";
|
||||
|
||||
export interface RestorePacketDeps {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
@@ -27,6 +35,13 @@ interface WriteOptions {
|
||||
targetRuntime?: string;
|
||||
targetWorkspaceRoot?: string;
|
||||
defaultTargetRepo?: string;
|
||||
rolePointer?: string;
|
||||
currentWorkSummary?: string;
|
||||
nextOwner?: string;
|
||||
caveat?: string[];
|
||||
authorityBoundaries?: string;
|
||||
sourceTrustRanking?: string;
|
||||
generatorVersion?: string;
|
||||
}
|
||||
|
||||
function reportFailure(message: string): void {
|
||||
@@ -34,11 +49,79 @@ function reportFailure(message: string): void {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
export function restorePacketCommand(_depsOverride?: RestorePacketDeps): Command {
|
||||
// _depsOverride is reserved for M2b/M2c when the source adapters need a
|
||||
// DaemonClient (full-read transcript route) and a LifecycleDeps for daemon
|
||||
// boot / status checks. M2a does not use either; the test suite passes
|
||||
// depsOverride? as undefined.
|
||||
function isValidRuntime(value: string | undefined): value is SourceRuntime {
|
||||
return value === "codex" || value === "claude-code";
|
||||
}
|
||||
|
||||
async function fetchSourceTranscriptViaDaemon(
|
||||
session: string,
|
||||
deps: RestorePacketDeps | undefined,
|
||||
): Promise<{ content: string; sourceCwd: string | null }> {
|
||||
const lifecycleDeps = deps?.lifecycleDeps ?? realDeps();
|
||||
const status = await getDaemonStatus(lifecycleDeps);
|
||||
if (status.state !== "running" || typeof status.port !== "number") {
|
||||
throw new Error("daemon not running; start it with: rig daemon start");
|
||||
}
|
||||
const url = getDaemonUrl(status);
|
||||
const client = (deps?.clientFactory ?? ((u: string) => new DaemonClient(u)))(url);
|
||||
// M2c-Daemon will define this route. M2c-CLI calls it; M2c-CLI tests
|
||||
// mock the daemon (compact-plan.test.ts:166-176 pattern). The path is
|
||||
// a draft — M2c-Daemon may pick a different naming convention; if so,
|
||||
// M2c-Daemon will land that change in restore-packet.ts the same commit
|
||||
// that adds the daemon route.
|
||||
const path = `/api/transcripts/${encodeURIComponent(session)}/full`;
|
||||
const response = await client.get<{ content: string; cwd?: string | null }>(path);
|
||||
if (response.status >= 400 || !response.data) {
|
||||
throw new Error(
|
||||
`daemon transcript fetch failed: HTTP ${response.status}: ${
|
||||
typeof response.data === "object" && response.data !== null
|
||||
? JSON.stringify(response.data)
|
||||
: "(no body)"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
content: response.data.content ?? "",
|
||||
sourceCwd: response.data.cwd ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWritePacketOptions(
|
||||
opts: WriteOptions,
|
||||
structured: StructuredTranscript,
|
||||
sourceRuntime: SourceRuntime,
|
||||
sourceCwdFallback: string,
|
||||
sourceSessionId: string,
|
||||
): WritePacketOptions {
|
||||
const trustRankingDefault = "rig_whoami,bounded_latest_transcript";
|
||||
const trustRankingRaw = opts.sourceTrustRanking ?? trustRankingDefault;
|
||||
const trustRanking = trustRankingRaw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
return {
|
||||
targetDir: resolvePath(opts.target),
|
||||
structured,
|
||||
sourceRuntime,
|
||||
targetRig: opts.targetRig ?? "openrig-velocity",
|
||||
targetRuntime: isValidRuntime(opts.targetRuntime) ? opts.targetRuntime : "claude-code",
|
||||
targetWorkspaceRoot: opts.targetWorkspaceRoot ?? structured.sessionMeta?.cwd ?? sourceCwdFallback,
|
||||
defaultTargetRepo: opts.defaultTargetRepo ?? null,
|
||||
rolePointer: opts.rolePointer ?? `rigs/${opts.targetRig ?? "openrig-velocity"}/state/velocity/role.md`,
|
||||
currentWorkSummary: opts.currentWorkSummary ?? "Cross-runtime restore packet generated by rig restore-packet write.",
|
||||
nextOwner: opts.nextOwner ?? "self",
|
||||
caveats: opts.caveat ?? [],
|
||||
authorityBoundaries: opts.authorityBoundaries ?? "Restored seat authority per source session role.",
|
||||
sourceTrustRanking: trustRanking,
|
||||
sourceSessionId,
|
||||
sourceCwd: structured.sessionMeta?.cwd ?? sourceCwdFallback,
|
||||
generatorVersion: opts.generatorVersion ?? "[email protected]",
|
||||
includeFullTranscript: structured.messages.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function restorePacketCommand(depsOverride?: RestorePacketDeps): Command {
|
||||
const cmd = new Command("restore-packet")
|
||||
.description(
|
||||
"Generate, read, and validate cross-runtime restore packets per the v0 standard",
|
||||
@@ -74,6 +157,36 @@ export function restorePacketCommand(_depsOverride?: RestorePacketDeps): Command
|
||||
"--default-target-repo <path>",
|
||||
"Default target repo absolute path or null",
|
||||
)
|
||||
.option(
|
||||
"--role-pointer <path>",
|
||||
"Role guidance pointer path or URI for the restored seat",
|
||||
)
|
||||
.option(
|
||||
"--current-work-summary <text>",
|
||||
"One-paragraph summary of the source seat's current work",
|
||||
)
|
||||
.option(
|
||||
"--next-owner <name>",
|
||||
'Who the restored seat hands off to next (default: "self")',
|
||||
)
|
||||
.option(
|
||||
"--caveat <text>",
|
||||
"Caveat to include in the packet (repeatable)",
|
||||
(value: string, prev: string[] = []) => prev.concat([value]),
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--authority-boundaries <text>",
|
||||
"Explicit statement of restored-seat authority",
|
||||
)
|
||||
.option(
|
||||
"--source-trust-ranking <csv>",
|
||||
"Comma-separated source-trust ranking; default: rig_whoami,bounded_latest_transcript",
|
||||
)
|
||||
.option(
|
||||
"--generator-version <version>",
|
||||
'Generator version identifier (default: "[email protected]")',
|
||||
)
|
||||
.action(async (opts: WriteOptions) => {
|
||||
const hasSession = typeof opts.sourceSession === "string" && opts.sourceSession.length > 0;
|
||||
const hasJsonl = typeof opts.sourceJsonl === "string" && opts.sourceJsonl.length > 0;
|
||||
@@ -89,15 +202,60 @@ export function restorePacketCommand(_depsOverride?: RestorePacketDeps): Command
|
||||
);
|
||||
return;
|
||||
}
|
||||
// M2b lands the actual write path. The mutual-exclusion checks above
|
||||
// are M2a-final; from here on (M2b) the source-adapter dispatch will
|
||||
// route to codex-jsonl-parser, claude-transcript-parser, or the
|
||||
// daemon full-read route.
|
||||
reportFailure(
|
||||
"M2a: source adapters land in M2b; this command is registered but not yet executable. (target was: " +
|
||||
opts.target +
|
||||
")",
|
||||
);
|
||||
|
||||
try {
|
||||
let content: string;
|
||||
let sourceCwdFallback: string;
|
||||
let sourceSessionId: string;
|
||||
if (hasJsonl) {
|
||||
const jsonlPath = resolvePath(opts.sourceJsonl!);
|
||||
content = readFileSync(jsonlPath, "utf-8");
|
||||
sourceCwdFallback = "/";
|
||||
sourceSessionId = jsonlPath;
|
||||
} else {
|
||||
const session = opts.sourceSession!;
|
||||
const fetched = await fetchSourceTranscriptViaDaemon(session, depsOverride);
|
||||
content = fetched.content;
|
||||
sourceCwdFallback = fetched.sourceCwd ?? "/";
|
||||
sourceSessionId = session;
|
||||
}
|
||||
|
||||
let runtime: SourceRuntime | null = null;
|
||||
if (isValidRuntime(opts.sourceRuntime)) {
|
||||
runtime = opts.sourceRuntime;
|
||||
} else if (typeof opts.sourceRuntime === "string" && opts.sourceRuntime.length > 0) {
|
||||
reportFailure(
|
||||
`--source-runtime must be 'codex' or 'claude-code'; got '${opts.sourceRuntime}'.`,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
runtime = detectRuntime(content);
|
||||
if (runtime === null) {
|
||||
reportFailure(
|
||||
"could not auto-detect source runtime; pass --source-runtime <claude-code|codex> explicitly.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const structured = runtime === "codex"
|
||||
? parseCodexJsonl(content)
|
||||
: parseClaudeTranscript(content);
|
||||
|
||||
const writeOpts = buildWritePacketOptions(opts, structured, runtime, sourceCwdFallback, sourceSessionId);
|
||||
const result = await writePacket(writeOpts);
|
||||
|
||||
// Per Quality Lesson v9: log only metadata, NOT transcript content.
|
||||
console.log(`packet: ${result.targetDir}`);
|
||||
console.log(`files: ${result.files.length}`);
|
||||
for (const f of result.files) {
|
||||
console.log(` - ${f}`);
|
||||
}
|
||||
console.log(`messages: ${structured.messageCount}`);
|
||||
console.log(`omitted-classes: ${Object.entries(structured.omittedCounts).filter(([, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(",") || "(none)"}`);
|
||||
} catch (err) {
|
||||
reportFailure((err as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("read")
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// packet-writer.ts — assembles + atomically writes a v0 restore packet
|
||||
// directory.
|
||||
//
|
||||
// Per M1 contract § 1: emits 4 required files (restore-instructions.md,
|
||||
// transcript-latest.md, touched-files.md, restore-summary.json) plus
|
||||
// optional transcript.md (when full transcript material is available;
|
||||
// presence in lockstep with `full_transcript` summary key).
|
||||
//
|
||||
// Atomic emission via tempdir-then-rename:
|
||||
// 1. Write all files into a sibling tempdir (`<targetDir>.tmp-<rand>/`).
|
||||
// 2. Validate the tempdir's restore-summary.json against the embedded
|
||||
// JSON Schema (RESTORE_SUMMARY_SCHEMA from schema-validator.ts).
|
||||
// 3. If valid: rename tempdir → targetDir (single fs.rename; atomic on
|
||||
// same filesystem).
|
||||
// 4. If validation fails OR any write step fails: rmSync the tempdir;
|
||||
// propagate the error. The targetDir is NEVER created in this case.
|
||||
//
|
||||
// Pre-condition: targetDir MUST NOT exist (operator must supply a fresh
|
||||
// path). Reject with explicit error otherwise.
|
||||
|
||||
import { mkdirSync, writeFileSync, existsSync, renameSync, rmSync, mkdtempSync } from "node:fs";
|
||||
import { dirname, basename, join } from "node:path";
|
||||
import {
|
||||
validateRestoreSummary,
|
||||
type ValidationResult,
|
||||
} from "./schema-validator.js";
|
||||
import type { StructuredTranscript, SourceRuntime } from "./types.js";
|
||||
|
||||
const TRANSCRIPT_BOUND = 120;
|
||||
|
||||
export interface WritePacketOptions {
|
||||
/** Absolute path of the target packet directory. MUST NOT exist. */
|
||||
targetDir: string;
|
||||
/** Parsed source structured representation. */
|
||||
structured: StructuredTranscript;
|
||||
/** Runtime kind of the source seat. */
|
||||
sourceRuntime: SourceRuntime;
|
||||
/** Target rig name (operator-supplied). */
|
||||
targetRig: string;
|
||||
/** Target runtime kind (operator-supplied). */
|
||||
targetRuntime: SourceRuntime;
|
||||
/** Target workspace root (absolute path). */
|
||||
targetWorkspaceRoot: string;
|
||||
/** Default target repo (absolute path or null). */
|
||||
defaultTargetRepo: string | null;
|
||||
/** Path/URI to role guidance for the restored seat. */
|
||||
rolePointer: string;
|
||||
/** One-paragraph human-readable summary of current work. */
|
||||
currentWorkSummary: string;
|
||||
/** Who the restored seat hands off to next; "self" if continuing. */
|
||||
nextOwner: string;
|
||||
/** Caveats list (cross-runtime quirks, redaction notes, etc.). */
|
||||
caveats: string[];
|
||||
/** Explicit statement of restored-seat authority. */
|
||||
authorityBoundaries: string;
|
||||
/** Ordered source-trust ranking (subset of contract enum). */
|
||||
sourceTrustRanking: string[];
|
||||
/** Source session id. */
|
||||
sourceSessionId: string;
|
||||
/** Source cwd (absolute path). */
|
||||
sourceCwd: string;
|
||||
/** Generator version identifier (e.g., "[email protected]"). */
|
||||
generatorVersion: string;
|
||||
/** Whether to include the full transcript.md file. */
|
||||
includeFullTranscript: boolean;
|
||||
}
|
||||
|
||||
export interface WritePacketResult {
|
||||
targetDir: string;
|
||||
files: string[];
|
||||
summaryPath: string;
|
||||
}
|
||||
|
||||
function transcriptMarkdownFromMessages(
|
||||
title: string,
|
||||
session: string,
|
||||
messages: StructuredTranscript["messages"],
|
||||
note: string,
|
||||
): string {
|
||||
const body = messages
|
||||
.map((m, i) => {
|
||||
const ts = m.timestamp ? ` ${m.timestamp}` : "";
|
||||
return `## ${i + 1}. ${m.role}${ts}\n\n${m.text}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
return `# ${title}\n\nSource session: \`${session}\`\n\n${note}\n\n${body}`;
|
||||
}
|
||||
|
||||
function buildTranscriptLatest(
|
||||
structured: StructuredTranscript,
|
||||
sourceSessionId: string,
|
||||
): string {
|
||||
const latest = structured.messages.slice(-TRANSCRIPT_BOUND);
|
||||
return transcriptMarkdownFromMessages(
|
||||
"Bounded Latest Transcript",
|
||||
sourceSessionId,
|
||||
latest,
|
||||
`This bounded transcript contains the latest ${latest.length} extracted messages for normal restore. Use transcript.md only when deeper forensic recovery is needed.`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildTranscriptFull(
|
||||
structured: StructuredTranscript,
|
||||
sourceSessionId: string,
|
||||
): string {
|
||||
return transcriptMarkdownFromMessages(
|
||||
"Full Transcript",
|
||||
sourceSessionId,
|
||||
structured.messages,
|
||||
"This transcript contains developer/user/assistant messages extracted from the source. Reasoning records, tool calls, and tool outputs are omitted by design (see restore-summary.json `omitted_classes`).",
|
||||
);
|
||||
}
|
||||
|
||||
function buildTouchedFiles(structured: StructuredTranscript): string {
|
||||
const rows = structured.paths.length > 0
|
||||
? structured.paths.map((entry) => `- \`${entry.path}\` (${entry.count})`).join("\n")
|
||||
: "- No paths detected in extracted messages or tool arguments.";
|
||||
return `# Touched Files And Path Inventory\n\nBest-effort path inventory from messages and tool arguments. This is a triage aid, not a complete proof of all files read or written.\n\n${rows}\n`;
|
||||
}
|
||||
|
||||
function buildRestoreInstructions(opts: WritePacketOptions): string {
|
||||
return `# Restore Instructions
|
||||
|
||||
You are a restored seat in \`${opts.targetRig}\`. This packet records the cross-runtime context for resuming work from \`${opts.sourceSessionId}\`.
|
||||
|
||||
## Identity
|
||||
|
||||
1. Run \`rig whoami --json\`.
|
||||
2. Trust the current rig identity over the source-session transcript.
|
||||
3. Current workspace root: \`${opts.targetWorkspaceRoot}\`.
|
||||
4. Default target repo: \`${opts.defaultTargetRepo ?? "(none)"}\`.
|
||||
5. Role guidance pointer: \`${opts.rolePointer}\`.
|
||||
|
||||
## Restore Steps
|
||||
|
||||
1. Read this file.
|
||||
2. Read \`touched-files.md\`.
|
||||
3. Read \`transcript-latest.md\` for bounded current context.
|
||||
4. Use \`transcript.md\` only if the bounded transcript is insufficient.
|
||||
5. State: "restored from packet generated by ${opts.generatorVersion}; current rig is ${opts.targetRig}; default target repo is ${opts.defaultTargetRepo ?? "none"}."
|
||||
6. Name any caveats before resuming work.
|
||||
7. Hand off to next owner: \`${opts.nextOwner}\` (or "self" to continue).
|
||||
|
||||
## Current Work
|
||||
|
||||
${opts.currentWorkSummary}
|
||||
|
||||
## Authority Boundaries
|
||||
|
||||
${opts.authorityBoundaries}
|
||||
|
||||
## Caveats
|
||||
|
||||
${opts.caveats.length > 0 ? opts.caveats.map((c) => `- ${c}`).join("\n") : "- (none recorded)"}
|
||||
|
||||
## Packet Stats
|
||||
|
||||
- Source runtime: \`${opts.sourceRuntime}\`
|
||||
- Target runtime: \`${opts.targetRuntime}\`
|
||||
- JSONL/transcript lines processed: ${opts.structured.lineCount}
|
||||
- Extracted messages: ${opts.structured.messageCount}
|
||||
- Compaction records: ${opts.structured.compactedCount}
|
||||
- Source cwd: \`${opts.sourceCwd}\`
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSummary(opts: WritePacketOptions): Record<string, unknown> {
|
||||
const omittedClassesActive: string[] = [];
|
||||
for (const k of ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"] as const) {
|
||||
if (opts.structured.omittedCounts[k] > 0) omittedClassesActive.push(k);
|
||||
}
|
||||
const messageCount = Math.min(opts.structured.messages.length, TRANSCRIPT_BOUND);
|
||||
const summary: Record<string, unknown> = {
|
||||
source_session_id: opts.sourceSessionId,
|
||||
source_rig: opts.sourceSessionId.includes("@") ? opts.sourceSessionId.split("@")[1] ?? "unknown" : "unknown",
|
||||
source_runtime: opts.sourceRuntime,
|
||||
source_cwd: opts.sourceCwd,
|
||||
target_rig: opts.targetRig,
|
||||
target_runtime: opts.targetRuntime,
|
||||
target_workspace_root: opts.targetWorkspaceRoot,
|
||||
default_target_repo: opts.defaultTargetRepo,
|
||||
role_pointer: opts.rolePointer,
|
||||
bounded_latest_transcript: {
|
||||
path: "transcript-latest.md",
|
||||
message_count: messageCount,
|
||||
bound: TRANSCRIPT_BOUND,
|
||||
},
|
||||
touched_files: {
|
||||
path: "touched-files.md",
|
||||
top_paths: opts.structured.paths.slice(0, 30),
|
||||
},
|
||||
durable_pointers: {
|
||||
queue_pointers: [],
|
||||
progress_pointers: [],
|
||||
field_note_pointers: [],
|
||||
artifact_pointers: [],
|
||||
},
|
||||
current_work_summary: opts.currentWorkSummary,
|
||||
next_owner: opts.nextOwner,
|
||||
caveats: opts.caveats,
|
||||
authority_boundaries: opts.authorityBoundaries,
|
||||
omitted_classes: omittedClassesActive,
|
||||
redaction_policy_id: "openrig-v0",
|
||||
source_trust_ranking: opts.sourceTrustRanking,
|
||||
generator_version: opts.generatorVersion,
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
if (opts.includeFullTranscript) {
|
||||
summary.full_transcript = {
|
||||
path: "transcript.md",
|
||||
line_count: opts.structured.messages.length,
|
||||
};
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a v0 restore packet directory atomically.
|
||||
*
|
||||
* Atomic guarantee: if any step fails (including schema validation of
|
||||
* restore-summary.json), the targetDir is never created and any
|
||||
* intermediate tempdir is removed. Operators see either a complete
|
||||
* valid packet at targetDir, or no packet at all.
|
||||
*/
|
||||
export async function writePacket(opts: WritePacketOptions): Promise<WritePacketResult> {
|
||||
if (existsSync(opts.targetDir)) {
|
||||
throw new Error(`restore-packet write: target directory already exists: ${opts.targetDir}`);
|
||||
}
|
||||
|
||||
// Build all file contents BEFORE creating any disk state. This way if
|
||||
// an in-memory step throws (unlikely, but defensive), no tempdir was
|
||||
// created.
|
||||
const summary = buildSummary(opts);
|
||||
const validation: ValidationResult = validateRestoreSummary(summary);
|
||||
if (!validation.valid) {
|
||||
const errSummary = validation.errors
|
||||
.slice(0, 5)
|
||||
.map((e) => `${e.field}: ${e.rule}`)
|
||||
.join("; ");
|
||||
throw new Error(
|
||||
`restore-packet write: schema validation failed before atomic rename: ${errSummary}`,
|
||||
);
|
||||
}
|
||||
|
||||
const restoreInstructions = buildRestoreInstructions(opts);
|
||||
const transcriptLatest = buildTranscriptLatest(opts.structured, opts.sourceSessionId);
|
||||
const touchedFiles = buildTouchedFiles(opts.structured);
|
||||
const transcriptFull = opts.includeFullTranscript
|
||||
? buildTranscriptFull(opts.structured, opts.sourceSessionId)
|
||||
: null;
|
||||
|
||||
// Create tempdir as a sibling of targetDir using mkdtemp for race-free
|
||||
// unique name. The dirname() must exist and be writable; we don't
|
||||
// mkdir the parent for the operator (operator authority bedrock).
|
||||
const parentDir = dirname(opts.targetDir);
|
||||
const tempPrefix = `${basename(opts.targetDir)}.tmp-restore-packet-`;
|
||||
const tempDir = mkdtempSync(join(parentDir, tempPrefix));
|
||||
|
||||
try {
|
||||
writeFileSync(join(tempDir, "restore-instructions.md"), restoreInstructions, "utf-8");
|
||||
writeFileSync(join(tempDir, "transcript-latest.md"), transcriptLatest, "utf-8");
|
||||
writeFileSync(join(tempDir, "touched-files.md"), touchedFiles, "utf-8");
|
||||
writeFileSync(join(tempDir, "restore-summary.json"), JSON.stringify(summary, null, 2), "utf-8");
|
||||
if (transcriptFull !== null) {
|
||||
writeFileSync(join(tempDir, "transcript.md"), transcriptFull, "utf-8");
|
||||
}
|
||||
|
||||
// Re-validate by parsing the on-disk summary (round-trip self-check).
|
||||
// This catches any structural divergence between buildSummary's
|
||||
// in-memory shape and the JSON.stringify() serialization.
|
||||
const onDisk = JSON.parse(
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
(await import("node:fs")).readFileSync(join(tempDir, "restore-summary.json"), "utf-8"),
|
||||
);
|
||||
const recheck = validateRestoreSummary(onDisk);
|
||||
if (!recheck.valid) {
|
||||
throw new Error(
|
||||
`restore-packet write: on-disk schema recheck failed: ${recheck.errors.slice(0, 5).map((e) => `${e.field}: ${e.rule}`).join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Atomic rename tempdir → targetDir. fs.rename is atomic when source
|
||||
// and destination are on the same filesystem; we keep them sibling
|
||||
// so this holds.
|
||||
renameSync(tempDir, opts.targetDir);
|
||||
} catch (err) {
|
||||
// Clean up tempdir on any failure; do NOT leave a partial packet.
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; original error takes precedence.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const files = [
|
||||
"restore-instructions.md",
|
||||
"transcript-latest.md",
|
||||
"touched-files.md",
|
||||
"restore-summary.json",
|
||||
];
|
||||
if (transcriptFull !== null) files.push("transcript.md");
|
||||
|
||||
return {
|
||||
targetDir: opts.targetDir,
|
||||
files,
|
||||
summaryPath: join(opts.targetDir, "restore-summary.json"),
|
||||
};
|
||||
}
|
||||
@@ -9,8 +9,22 @@
|
||||
// fail first, then the implementation was written to green. The committed
|
||||
// state is the green/passing state.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
// Module-level mock for daemon-lifecycle so the M2c-CLI mock-daemon
|
||||
// round-trip test can drive a fake "daemon running" state without
|
||||
// touching the host's real daemon. Other tests in this file don't
|
||||
// invoke daemon-lifecycle (they exercise pure parsers / packet-writer),
|
||||
// so they're unaffected.
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -799,3 +813,388 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
expect(r.omittedCounts.raw_tool_outputs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// M2c-CLI: packet-writer atomic emission + round-trip tests.
|
||||
//
|
||||
// packet-writer.ts assembles a v0 restore packet directory atomically:
|
||||
// writes to a tempdir, validates the resulting restore-summary.json
|
||||
// against the embedded JSON Schema, then renames tempdir → target.
|
||||
// On any validation failure, the tempdir is removed; no partial packet
|
||||
// is left in the operator's filesystem.
|
||||
//
|
||||
// Per M1 contract § 1: directory contains 4 required files
|
||||
// (restore-instructions.md, transcript-latest.md, touched-files.md,
|
||||
// restore-summary.json) plus optional transcript.md.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("M2c-CLI packet-writer atomic emission", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "restore-packet-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildBaselineOpts(targetDir: string): Record<string, unknown> {
|
||||
return {
|
||||
targetDir,
|
||||
structured: parseCodexJsonl(`{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/projects/openrig-hub","id":"src-session-id"}}
|
||||
{"type":"response_item","timestamp":"2026-05-02T01:00:00Z","payload":{"type":"message","role":"user","content":"Walk through the M1 contract"}}
|
||||
{"type":"response_item","timestamp":"2026-05-02T01:00:01Z","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"Per § 2.1 the schema requires 21 fields"}]}}`),
|
||||
sourceRuntime: "codex" as const,
|
||||
targetRig: "openrig-velocity-claude",
|
||||
targetRuntime: "claude-code" as const,
|
||||
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
|
||||
defaultTargetRepo: "/Users/wrandom/code/projects/openrig-hub/openrig",
|
||||
rolePointer: "rigs/openrig-velocity-claude/state/velocity/driver-role.md",
|
||||
currentWorkSummary: "Working on Restore-Packet vertical M2c-CLI chunk.",
|
||||
nextOwner: "self",
|
||||
caveats: ["Cross-runtime restore from Codex JSONL to Claude Code seat."],
|
||||
authorityBoundaries: "May edit packages/cli/ and packages/daemon/ within M2c boundary; no M3+ surfaces.",
|
||||
sourceTrustRanking: ["rig_whoami", "bounded_latest_transcript"],
|
||||
sourceSessionId: "velocity-driver@openrig-velocity",
|
||||
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
generatorVersion: "[email protected]",
|
||||
includeFullTranscript: false,
|
||||
};
|
||||
}
|
||||
|
||||
it("emits 4 required files + restore-summary.json schema-valid", async () => {
|
||||
const { writePacket } = await import("../src/restore-packet/packet-writer.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const targetDir = path.join(tmpRoot, "packet-1");
|
||||
const result = await writePacket(buildBaselineOpts(targetDir) as Parameters<typeof writePacket>[0]);
|
||||
|
||||
expect(result.targetDir).toBe(targetDir);
|
||||
for (const required of ["restore-instructions.md", "transcript-latest.md", "touched-files.md", "restore-summary.json"]) {
|
||||
expect(fs.existsSync(path.join(targetDir, required)), `missing ${required}`).toBe(true);
|
||||
}
|
||||
// Optional transcript.md should NOT be present when includeFullTranscript=false.
|
||||
expect(fs.existsSync(path.join(targetDir, "transcript.md"))).toBe(false);
|
||||
|
||||
// Summary parses and schema-validates.
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(targetDir, "restore-summary.json"), "utf-8"));
|
||||
const validation = validateRestoreSummary(summary);
|
||||
expect(validation.valid, JSON.stringify(validation.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it("emits transcript.md when includeFullTranscript=true; full_transcript key present in summary", async () => {
|
||||
const { writePacket } = await import("../src/restore-packet/packet-writer.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const targetDir = path.join(tmpRoot, "packet-full");
|
||||
const opts = buildBaselineOpts(targetDir);
|
||||
(opts as Record<string, unknown>)["includeFullTranscript"] = true;
|
||||
await writePacket(opts as Parameters<typeof writePacket>[0]);
|
||||
|
||||
expect(fs.existsSync(path.join(targetDir, "transcript.md"))).toBe(true);
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(targetDir, "restore-summary.json"), "utf-8"));
|
||||
expect(summary.full_transcript).toBeDefined();
|
||||
expect(typeof summary.full_transcript.path).toBe("string");
|
||||
expect(typeof summary.full_transcript.line_count).toBe("number");
|
||||
});
|
||||
|
||||
it("populates contract § 2.1 required fields verbatim from operator-supplied options", async () => {
|
||||
const { writePacket } = await import("../src/restore-packet/packet-writer.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const targetDir = path.join(tmpRoot, "packet-fields");
|
||||
await writePacket(buildBaselineOpts(targetDir) as Parameters<typeof writePacket>[0]);
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(targetDir, "restore-summary.json"), "utf-8"));
|
||||
expect(summary.target_rig).toBe("openrig-velocity-claude");
|
||||
expect(summary.target_runtime).toBe("claude-code");
|
||||
expect(summary.role_pointer).toBe("rigs/openrig-velocity-claude/state/velocity/driver-role.md");
|
||||
expect(summary.bounded_latest_transcript.bound).toBe(120);
|
||||
expect(summary.redaction_policy_id).toBe("openrig-v0");
|
||||
expect(summary.generator_version).toBe("[email protected]");
|
||||
expect(summary.source_runtime).toBe("codex");
|
||||
expect(summary.source_session_id).toBe("velocity-driver@openrig-velocity");
|
||||
expect(Array.isArray(summary.touched_files.top_paths)).toBe(true);
|
||||
expect(summary.durable_pointers.queue_pointers).toBeDefined();
|
||||
expect(summary.durable_pointers.progress_pointers).toBeDefined();
|
||||
expect(summary.durable_pointers.field_note_pointers).toBeDefined();
|
||||
expect(summary.durable_pointers.artifact_pointers).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects when target directory already exists", async () => {
|
||||
const { writePacket } = await import("../src/restore-packet/packet-writer.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const targetDir = path.join(tmpRoot, "preexisting");
|
||||
fs.mkdirSync(targetDir);
|
||||
fs.writeFileSync(path.join(targetDir, "marker.txt"), "do not overwrite");
|
||||
|
||||
const opts = buildBaselineOpts(targetDir) as Parameters<typeof writePacket>[0];
|
||||
await expect(writePacket(opts)).rejects.toThrow(/exists|already/i);
|
||||
// Marker preserved (no partial overwrite).
|
||||
expect(fs.readFileSync(path.join(targetDir, "marker.txt"), "utf-8")).toBe("do not overwrite");
|
||||
});
|
||||
|
||||
it("atomic emission: schema-validation failure mid-write leaves NO target dir AND cleans tempdir", async () => {
|
||||
const { writePacket } = await import("../src/restore-packet/packet-writer.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const targetDir = path.join(tmpRoot, "packet-fail");
|
||||
const opts = buildBaselineOpts(targetDir) as Record<string, unknown>;
|
||||
// Inject a contract violation: empty current_work_summary should fail
|
||||
// schema validation (minLength: 1).
|
||||
opts["currentWorkSummary"] = "";
|
||||
|
||||
await expect(
|
||||
writePacket(opts as Parameters<typeof writePacket>[0]),
|
||||
).rejects.toThrow(/schema|valid|current_work_summary/i);
|
||||
|
||||
// Target dir was NEVER created (atomic rename semantics).
|
||||
expect(fs.existsSync(targetDir)).toBe(false);
|
||||
// No leftover .tmp- prefixed dirs in tmpRoot.
|
||||
const tmpRootContents = fs.readdirSync(tmpRoot);
|
||||
const leftovers = tmpRootContents.filter((name) => name.includes(".tmp-restore-packet-"));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it("transcript-latest.md bounded at 120 messages; message_count reflects actual count", async () => {
|
||||
const { writePacket } = await import("../src/restore-packet/packet-writer.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
|
||||
// Build 150 user messages (exceeds the 120 bound).
|
||||
const lines: string[] = [`{"type":"session_meta","payload":{"cwd":"/x"}}`];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
lines.push(`{"type":"response_item","payload":{"type":"message","role":"user","content":"msg ${i}"}}`);
|
||||
}
|
||||
const opts = buildBaselineOpts(path.join(tmpRoot, "packet-bound")) as Record<string, unknown>;
|
||||
opts["structured"] = parseCodexJsonl(lines.join("\n"));
|
||||
await writePacket(opts as Parameters<typeof writePacket>[0]);
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(tmpRoot, "packet-bound", "restore-summary.json"), "utf-8"));
|
||||
expect(summary.bounded_latest_transcript.bound).toBe(120);
|
||||
expect(summary.bounded_latest_transcript.message_count).toBe(120);
|
||||
|
||||
// Latest transcript file contains exactly 120 sectioned messages.
|
||||
const latest = fs.readFileSync(path.join(tmpRoot, "packet-bound", "transcript-latest.md"), "utf-8");
|
||||
const sectionCount = (latest.match(/^## \d+\. /gm) ?? []).length;
|
||||
expect(sectionCount).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "restore-packet-rt-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Build a synthetic Codex JSONL with the same structural shape as
|
||||
// Velocity prior art: session_meta + response_item messages + tool_calls
|
||||
// + reasoning records + a credential-pattern needle for redaction.
|
||||
function syntheticCodexJsonlFor(role: string): string {
|
||||
return [
|
||||
`{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/projects/openrig-hub","id":"velocity-${role}@openrig-velocity-code"}}`,
|
||||
`{"type":"response_item","payload":{"type":"reasoning","content":"<thinking>"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"packages/cli/src/index.ts\\"}"}}`,
|
||||
`{"type":"response_item","timestamp":"2026-04-23T18:08:00Z","payload":{"type":"message","role":"user","content":"${role}: please review the diff"}}`,
|
||||
`{"type":"response_item","timestamp":"2026-04-23T18:08:30Z","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"${role}: token sk-FakeAbCdEfGhIjKlMn was visible in logs"}]}}`,
|
||||
`{"type":"compacted","payload":{}}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
for (const role of ["driver", "guard", "planner", "tester"]) {
|
||||
it(`Velocity-shape ${role} round-trip: parser → packet-writer → schema-valid; counts + redaction preserved`, async () => {
|
||||
const { writePacket } = await import("../src/restore-packet/packet-writer.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const structured = parseCodexJsonl(syntheticCodexJsonlFor(role));
|
||||
|
||||
// Sanity-check parser output before writer round-trip.
|
||||
expect(structured.messageCount).toBe(2);
|
||||
expect(structured.compactedCount).toBe(1);
|
||||
expect(structured.omittedCounts.reasoning_records).toBeGreaterThanOrEqual(1);
|
||||
expect(structured.omittedCounts.function_call_output).toBe(1);
|
||||
expect(structured.omittedCounts.redacted_secrets).toBe(1);
|
||||
|
||||
const targetDir = path.join(tmpRoot, `packet-${role}`);
|
||||
await writePacket({
|
||||
targetDir,
|
||||
structured,
|
||||
sourceRuntime: "codex",
|
||||
targetRig: "openrig-velocity-claude",
|
||||
targetRuntime: "claude-code",
|
||||
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
|
||||
defaultTargetRepo: "/Users/wrandom/code/projects/openrig-hub/openrig",
|
||||
rolePointer: `rigs/openrig-velocity-claude/state/velocity/${role}-role.md`,
|
||||
currentWorkSummary: `Synthetic ${role} round-trip for Velocity-shape parity.`,
|
||||
nextOwner: "self",
|
||||
caveats: [],
|
||||
authorityBoundaries: `${role} authority for Velocity-replay context.`,
|
||||
sourceTrustRanking: ["rig_whoami", "bounded_latest_transcript"],
|
||||
sourceSessionId: `velocity-${role}@openrig-velocity-code`,
|
||||
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
generatorVersion: "[email protected]",
|
||||
includeFullTranscript: true,
|
||||
});
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(targetDir, "restore-summary.json"), "utf-8"));
|
||||
const validation = validateRestoreSummary(summary);
|
||||
expect(validation.valid, JSON.stringify(validation.errors)).toBe(true);
|
||||
// Provenance preserved at relevant fields:
|
||||
expect(summary.source_session_id).toBe(`velocity-${role}@openrig-velocity-code`);
|
||||
expect(summary.source_cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
|
||||
expect(summary.bounded_latest_transcript.message_count).toBe(2);
|
||||
// Omitted-class enumeration matches what the parser counted:
|
||||
expect(summary.omitted_classes).toContain("reasoning_records");
|
||||
expect(summary.omitted_classes).toContain("function_call_output");
|
||||
expect(summary.omitted_classes).toContain("redacted_secrets");
|
||||
// Redacted output: assistant's "sk-FakeAbCdEfGhIjKlMn" must NOT appear in transcript.md.
|
||||
const fullT = fs.readFileSync(path.join(targetDir, "transcript.md"), "utf-8");
|
||||
expect(fullT).not.toContain("sk-FakeAbCdEfGhIjKlMn");
|
||||
expect(fullT).toContain("[REDACTED]");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "restore-packet-er-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("mock-daemon --source-session round-trip writes packet and forwards no mutation", async () => {
|
||||
const { restorePacketCommand } = await import("../src/commands/restore-packet.js");
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
|
||||
const requestedPaths: string[] = [];
|
||||
const mutationMethods = {
|
||||
post: vi.fn(async () => { throw new Error("mock-daemon: post should not be called by restore-packet write"); }),
|
||||
delete: vi.fn(async () => { throw new Error("mock-daemon: delete should not be called by restore-packet write"); }),
|
||||
postText: vi.fn(async () => { throw new Error("mock-daemon: postText should not be called"); }),
|
||||
postExpectText: vi.fn(async () => { throw new Error("mock-daemon: postExpectText should not be called"); }),
|
||||
};
|
||||
// Synthetic Codex JSONL the mock-daemon serves on the new full-read route.
|
||||
const fixtureContent = `{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/x","id":"src-session"}}
|
||||
{"type":"response_item","payload":{"type":"message","role":"user","content":"hello from session"}}
|
||||
{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"reply"}]}}`;
|
||||
|
||||
const deps = {
|
||||
lifecycleDeps: {} as Parameters<typeof restorePacketCommand>[0] extends undefined ? never : NonNullable<Parameters<typeof restorePacketCommand>[0]>["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (p: string) => {
|
||||
requestedPaths.push(p);
|
||||
if (p === "/api/transcripts/src-session/full") {
|
||||
return { status: 200, data: { content: fixtureContent, cwd: "/Users/wrandom/code/x" } };
|
||||
}
|
||||
return { status: 404, data: { error: "not found" } };
|
||||
}),
|
||||
getText: vi.fn(async () => ({ status: 200, data: "" })),
|
||||
...mutationMethods,
|
||||
}),
|
||||
};
|
||||
|
||||
const targetDir = path.join(tmpRoot, "via-daemon");
|
||||
const program = createProgram({ restorePacketDeps: deps as unknown as Parameters<typeof createProgram>[0]["restorePacketDeps"] });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "restore-packet", "write",
|
||||
"--source-session", "src-session",
|
||||
"--target", targetDir,
|
||||
"--target-rig", "openrig-velocity-claude",
|
||||
"--target-runtime", "claude-code",
|
||||
]);
|
||||
|
||||
// Daemon was queried at the new full-read route; no mutation methods called.
|
||||
expect(requestedPaths).toContain("/api/transcripts/src-session/full");
|
||||
expect(mutationMethods.post).not.toHaveBeenCalled();
|
||||
expect(mutationMethods.delete).not.toHaveBeenCalled();
|
||||
// Packet emitted.
|
||||
expect(fs.existsSync(targetDir)).toBe(true);
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(targetDir, "restore-summary.json"), "utf-8"));
|
||||
const validation = validateRestoreSummary(summary);
|
||||
expect(validation.valid, JSON.stringify(validation.errors)).toBe(true);
|
||||
expect(summary.source_session_id).toBe("src-session");
|
||||
});
|
||||
|
||||
it("M2b R2 nested Claude content fixture: round-trips with non-zero omittedCounts in summary", async () => {
|
||||
const { writePacket } = await import("../src/restore-packet/packet-writer.js");
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const content = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_1", name: "Bash", input: { cmd: "pwd" } },
|
||||
{ type: "text", text: "checking now" },
|
||||
],
|
||||
},
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
sessionId: "claude-session-id",
|
||||
}) + "\n" + JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/wrandom/code/projects/openrig-hub" },
|
||||
],
|
||||
},
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
sessionId: "claude-session-id",
|
||||
});
|
||||
const structured = parseClaudeTranscript(content);
|
||||
|
||||
const targetDir = path.join(tmpRoot, "claude-nested");
|
||||
await writePacket({
|
||||
targetDir,
|
||||
structured,
|
||||
sourceRuntime: "claude-code",
|
||||
targetRig: "openrig-velocity-claude",
|
||||
targetRuntime: "claude-code",
|
||||
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
|
||||
defaultTargetRepo: null,
|
||||
rolePointer: "rigs/openrig-velocity-claude/state/velocity/driver-role.md",
|
||||
currentWorkSummary: "Nested-content round-trip for M2c-CLI.",
|
||||
nextOwner: "self",
|
||||
caveats: [],
|
||||
authorityBoundaries: "Velocity authority.",
|
||||
sourceTrustRanking: ["rig_whoami"],
|
||||
sourceSessionId: "claude-session-id",
|
||||
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
generatorVersion: "[email protected]",
|
||||
includeFullTranscript: false,
|
||||
});
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(targetDir, "restore-summary.json"), "utf-8"));
|
||||
expect(summary.omitted_classes).toContain("function_call_output");
|
||||
expect(summary.omitted_classes).toContain("raw_tool_outputs");
|
||||
// Touched-files inventory captured the path from the omitted tool_result.
|
||||
expect(summary.touched_files.top_paths.some((p: { path: string }) =>
|
||||
p.path === "/Users/wrandom/code/projects/openrig-hub")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user