mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(cli): Restore-Packet vertical M2a — schema + validator + CLI shell + wiring
Per orch decision (Path B chunked M2; M2a chunk):
- NEW packages/cli/src/schemas/restore-summary.schema.json — JSON Schema
draft-07 for restore-summary.json, derived byte-for-byte from M1 contract
§ 2.1 at substrate/openrig-work/specs/restore-packet/SCHEMA.md. 21 required
fields (16 standard + 5 v1-promoted-to-v0-required) plus the optional
full_transcript object. Nested required-shapes for bounded_latest_transcript
(with const bound: 120), touched_files, and durable_pointers (4 required
arrays). additionalProperties: false at the root.
- NEW packages/cli/src/restore-packet/schema-validator.ts — TypeScript
validator using ajv 8.18.0 + ajv-formats 3.0.1 (added to deps). Loads the
schema file once (module-level cache), compiles into an ajv validator,
and returns per-field ValidationError records (field, value, rule,
severity) per contract § 8 reporting shape. M3 validate command + M2c
generator-side emit-time self-check both consume this module.
- NEW packages/cli/src/commands/restore-packet.ts — commander command shape
per IMPL § M2 line 105-141. Three subcommands: write / read / validate.
M2a implements write's mutual-exclusion gate (--source-session vs
--source-jsonl: exactly-one-required) and the missing-target check via
commander requiredOption. read + validate are M2a stubs that report an
M3 deadline; their CLI surfaces are registered now so the command tree
is discoverable.
- EDIT packages/cli/src/index.ts — register restorePacketCommand after
restoreCheckCommand (alphabetical: restore < restore-check < restore-packet
per re-anchor finding); wire RestorePacketDeps into ProgramDeps.
- EDIT packages/cli/package.json — add ajv ^8.18.0 + ajv-formats ^3.0.1 to
dependencies. Both already hoisted in monorepo node_modules at the same
versions; this commit declares them honestly.
- NEW packages/cli/test/restore-packet.test.ts — 17 Tier 1 tests:
- 11 schema-validator accept/reject tests covering each required-field
family (source_session_id required; source_runtime enum; source_cwd
pattern; bounded_latest_transcript.bound const 120; durable_pointers
nested required arrays; redaction_policy_id enum; source_trust_ranking
minItems; generated_at format; additionalProperties: false; optional
full_transcript accepted; valid baseline accepted).
- 6 CLI shell tests: subcommands present (read/validate/write); write
mutual-exclusion both supplied; write neither supplied; --target
missing; read M2a stub registered; validate M2a stub registered.
TDD discipline (per memory feedback_tdd_scope): test file authored before
schema-validator.ts and restore-packet.ts (failing import), then
implementations brought the suite to green. Committed state is the green
state.
Test runs:
- packages/cli vitest run test/restore-packet.test.ts → 17/17 pass.
- packages/cli vitest run (full suite) → 657/661 pass; the 4 failures are
the pre-existing environmental flakes from prior session (preflight x2:
port 7433 in use; chatroom wait: 10s timeout flake; config-store: env
pollution). All four touch zero files in this M2a commit. No regression.
Posture declared: full QA/testing window. Out of scope for M2a (per orch
chunked decision): codex-jsonl-parser / claude-transcript-parser /
runtime-detect / redaction / omitted-records / packet-writer (M2b);
daemon route extension + daemon route tests (M2c); read/validate command
implementations (M3); M4-M6 proof work.
Source qitem: qitem-20260501232012-e660c0ce (Restore-Packet vertical M2).
Orch chunked-M2 decision: arrived as inline reply on the scope-check flag
qitem-20260501232237-7748b7d6.
This commit is contained in:
1 parent
6af2754824
commit
de2dbd4f3a
6 files changed
+563
No files matched your search
@@ -44,6 +44,8 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"commander": "^13.0.0",
|
||||
"hono": "^4.7.0",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
|
||||
export interface RestorePacketDeps {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
clientFactory?: (url: string) => DaemonClient;
|
||||
}
|
||||
|
||||
interface WriteOptions {
|
||||
sourceSession?: string;
|
||||
sourceJsonl?: string;
|
||||
sourceRuntime?: string;
|
||||
target: string;
|
||||
targetRig?: string;
|
||||
targetRuntime?: string;
|
||||
targetWorkspaceRoot?: string;
|
||||
defaultTargetRepo?: string;
|
||||
}
|
||||
|
||||
function reportFailure(message: string): void {
|
||||
console.error(`rig restore-packet write: ${message}`);
|
||||
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.
|
||||
const cmd = new Command("restore-packet")
|
||||
.description(
|
||||
"Generate, read, and validate cross-runtime restore packets per the v0 standard",
|
||||
);
|
||||
|
||||
cmd.command("write")
|
||||
.description(
|
||||
"Generate a restore packet from a source session or JSONL file",
|
||||
)
|
||||
.option("--source-session <session>", "Source session name (daemon-backed)")
|
||||
.option(
|
||||
"--source-jsonl <path>",
|
||||
"Source Codex/Claude JSONL transcript file (direct)",
|
||||
)
|
||||
.option(
|
||||
"--source-runtime <runtime>",
|
||||
"Force source runtime (claude-code | codex); auto-detected if omitted",
|
||||
)
|
||||
.requiredOption(
|
||||
"--target <dir>",
|
||||
"Target packet directory (must not exist; created atomically)",
|
||||
)
|
||||
.option("--target-rig <rig>", "Target rig name for the restored seat")
|
||||
.option(
|
||||
"--target-runtime <runtime>",
|
||||
"Target runtime (claude-code | codex)",
|
||||
)
|
||||
.option(
|
||||
"--target-workspace-root <path>",
|
||||
"Target workspace root absolute path",
|
||||
)
|
||||
.option(
|
||||
"--default-target-repo <path>",
|
||||
"Default target repo absolute path or null",
|
||||
)
|
||||
.action(async (opts: WriteOptions) => {
|
||||
const hasSession = typeof opts.sourceSession === "string" && opts.sourceSession.length > 0;
|
||||
const hasJsonl = typeof opts.sourceJsonl === "string" && opts.sourceJsonl.length > 0;
|
||||
if (hasSession && hasJsonl) {
|
||||
reportFailure(
|
||||
"--source-session and --source-jsonl are mutually exclusive; supply exactly one.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!hasSession && !hasJsonl) {
|
||||
reportFailure(
|
||||
"exactly one of --source-session or --source-jsonl is required.",
|
||||
);
|
||||
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 +
|
||||
")",
|
||||
);
|
||||
});
|
||||
|
||||
cmd.command("read")
|
||||
.description("Render a restore packet contents (human or --json)")
|
||||
.argument("<packet-dir>", "Packet directory path")
|
||||
.option("--json", "Emit machine-readable JSON output")
|
||||
.action(async (packetDir: string, _opts: { json?: boolean }) => {
|
||||
console.error(
|
||||
`rig restore-packet read: M3 implementation pending; packet-dir was '${packetDir}'.`,
|
||||
);
|
||||
process.exitCode = 2;
|
||||
});
|
||||
|
||||
cmd.command("validate")
|
||||
.description("Validate a restore packet against the v0 schema")
|
||||
.argument("<packet-dir>", "Packet directory path")
|
||||
.option("--json", "Emit machine-readable validation report")
|
||||
.action(async (packetDir: string, _opts: { json?: boolean }) => {
|
||||
console.error(
|
||||
`rig restore-packet validate: M3 implementation pending; packet-dir was '${packetDir}'.`,
|
||||
);
|
||||
process.exitCode = 2;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
|
||||
import { setupCommand } from "./commands/setup.js";
|
||||
import { contextCommand } from "./commands/context.js";
|
||||
import { restoreCheckCommand } from "./commands/restore-check.js";
|
||||
import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore-packet.js";
|
||||
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, type SeatDeps } from "./commands/seat.js";
|
||||
@@ -89,6 +90,7 @@ export interface ProgramDeps {
|
||||
removeDeps?: StatusDeps;
|
||||
shrinkDeps?: StatusDeps;
|
||||
destroyDeps?: DestroyCommandDeps;
|
||||
restorePacketDeps?: RestorePacketDeps;
|
||||
compactPlanDeps?: CompactPlanDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
@@ -146,6 +148,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(setupCommand());
|
||||
program.addCommand(contextCommand());
|
||||
program.addCommand(restoreCheckCommand());
|
||||
program.addCommand(restorePacketCommand(depsOverride?.restorePacketDeps));
|
||||
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
|
||||
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
|
||||
program.addCommand(seatCommand(depsOverride?.seatDeps));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// schema-validator.ts — validates restore-summary.json against the v0 JSON
|
||||
// Schema (packages/cli/src/schemas/restore-summary.schema.json).
|
||||
//
|
||||
// Per M1 contract § 8 + IMPL § M2 line 167:
|
||||
// - generator-side: emit-time self-check against the schema before atomic
|
||||
// rename of the packet directory.
|
||||
// - operator-side: M3 `rig restore-packet validate <packet-dir>` runs this
|
||||
// validator and surfaces per-field violations.
|
||||
//
|
||||
// Validator returns a ValidationResult with `valid` and a per-field error
|
||||
// list. Each error names: field path, value (truncated/escaped), rule, and
|
||||
// severity. Required-field violations are severity `error`; optional-field
|
||||
// malformations are severity `warning` per § 8.
|
||||
|
||||
import Ajv from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
value: string;
|
||||
rule: string;
|
||||
severity: "error" | "warning";
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
let cachedValidator: ReturnType<Ajv["compile"]> | null = null;
|
||||
|
||||
function loadSchema(): object {
|
||||
// src/restore-packet/schema-validator.ts → src/schemas/restore-summary.schema.json
|
||||
const schemaPath = resolve(__dirname, "..", "schemas", "restore-summary.schema.json");
|
||||
return JSON.parse(readFileSync(schemaPath, "utf-8")) as object;
|
||||
}
|
||||
|
||||
function getValidator(): ReturnType<Ajv["compile"]> {
|
||||
if (cachedValidator) return cachedValidator;
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
cachedValidator = ajv.compile(loadSchema());
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
function truncateValue(value: unknown): string {
|
||||
if (value === undefined) return "<undefined>";
|
||||
if (value === null) return "null";
|
||||
let s: string;
|
||||
try {
|
||||
s = typeof value === "string" ? value : JSON.stringify(value);
|
||||
} catch {
|
||||
s = String(value);
|
||||
}
|
||||
if (s.length > 80) return s.slice(0, 77) + "...";
|
||||
return s;
|
||||
}
|
||||
|
||||
export function validateRestoreSummary(summary: unknown): ValidationResult {
|
||||
const validator = getValidator();
|
||||
const ok = validator(summary);
|
||||
if (ok) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
const errors: ValidationError[] = (validator.errors ?? []).map((err) => {
|
||||
const instancePath = err.instancePath || "";
|
||||
const missing = err.params && (err.params as { missingProperty?: string }).missingProperty;
|
||||
const additional = err.params && (err.params as { additionalProperty?: string }).additionalProperty;
|
||||
let field = instancePath.replace(/^\//, "").replace(/\//g, ".");
|
||||
if (missing) field = field ? `${field}.${missing}` : missing;
|
||||
if (additional) field = additional;
|
||||
return {
|
||||
field: field || "<root>",
|
||||
value: truncateValue(err.data),
|
||||
rule: `${err.keyword}: ${err.message ?? ""}`.trim(),
|
||||
severity: "error",
|
||||
};
|
||||
});
|
||||
return { valid: false, errors };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://openrig.dev/schemas/restore-summary.schema.json",
|
||||
"title": "Restore Packet Summary",
|
||||
"description": "Machine-readable metadata for a cross-runtime restore packet per the v0 paper standard.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source_session_id",
|
||||
"source_rig",
|
||||
"source_runtime",
|
||||
"source_cwd",
|
||||
"target_rig",
|
||||
"target_runtime",
|
||||
"target_workspace_root",
|
||||
"default_target_repo",
|
||||
"role_pointer",
|
||||
"bounded_latest_transcript",
|
||||
"touched_files",
|
||||
"durable_pointers",
|
||||
"current_work_summary",
|
||||
"next_owner",
|
||||
"caveats",
|
||||
"authority_boundaries",
|
||||
"omitted_classes",
|
||||
"redaction_policy_id",
|
||||
"source_trust_ranking",
|
||||
"generator_version",
|
||||
"generated_at"
|
||||
],
|
||||
"properties": {
|
||||
"source_session_id": {"type": "string", "minLength": 1},
|
||||
"source_rig": {"type": "string", "minLength": 1},
|
||||
"source_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
|
||||
"source_cwd": {"type": "string", "pattern": "^/"},
|
||||
"target_rig": {"type": "string", "minLength": 1},
|
||||
"target_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
|
||||
"target_workspace_root": {"type": "string", "pattern": "^/"},
|
||||
"default_target_repo": {"type": ["string", "null"]},
|
||||
"role_pointer": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Path or URI pointing to the role guidance file the restored seat should consume. REQUIRED per v0 standard field #9; resolves at restore time."
|
||||
},
|
||||
"bounded_latest_transcript": {
|
||||
"type": "object",
|
||||
"required": ["path", "message_count", "bound"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"message_count": {"type": "integer", "minimum": 0, "description": "Actual count of extracted messages in transcript-latest.md."},
|
||||
"bound": {"type": "integer", "const": 120, "description": "v0 bound is fixed at 120 messages to match Velocity prior art (per contract § 7). v1+ may make configurable; v0 schema requires const 120 so packets with the wrong bound cannot pass."}
|
||||
}
|
||||
},
|
||||
"full_transcript": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"line_count": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"description": "Optional per v0 standard field #11. Generator emits when full transcript material is available (e.g., from --source-jsonl or full-read --source-session); omitted otherwise."
|
||||
},
|
||||
"touched_files": {
|
||||
"type": "object",
|
||||
"required": ["path", "top_paths"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"top_paths": {"type": "array", "items": {"type": "object", "required": ["path", "count"], "properties": {"path": {"type": "string"}, "count": {"type": "integer", "minimum": 0}}}}
|
||||
}
|
||||
},
|
||||
"durable_pointers": {
|
||||
"type": "object",
|
||||
"required": ["queue_pointers", "progress_pointers", "field_note_pointers", "artifact_pointers"],
|
||||
"properties": {
|
||||
"queue_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to durable queue items (qitem ids or queue-file paths)."},
|
||||
"progress_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to PROGRESS.md cursors the source seat was operating against."},
|
||||
"field_note_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to field-notes folders or files."},
|
||||
"artifact_pointers": {"type": "array", "items": {"type": "string"}, "description": "Other durable artifacts (proof packets, slice packets, candidate dossiers)."}
|
||||
},
|
||||
"description": "REQUIRED per v0 standard field #13. Pointers to durable work the source seat was operating against; restored seat reads to recover work-context."
|
||||
},
|
||||
"current_work_summary": {"type": "string", "minLength": 1},
|
||||
"next_owner": {"type": "string", "minLength": 1},
|
||||
"caveats": {"type": "array", "items": {"type": "string"}},
|
||||
"authority_boundaries": {"type": "string", "minLength": 1},
|
||||
"omitted_classes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"]}
|
||||
},
|
||||
"redaction_policy_id": {"type": "string", "enum": ["velocity-v1", "openrig-v0"]},
|
||||
"source_trust_ranking": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["rig_whoami", "target_rigspec", "bounded_latest_transcript", "full_transcript", "touched_files", "restore_summary"]},
|
||||
"minItems": 1
|
||||
},
|
||||
"generator_version": {"type": "string", "minLength": 1},
|
||||
"generated_at": {"type": "string", "format": "date-time"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// M2 Restore-Packet vertical — Tier 1 tests.
|
||||
//
|
||||
// M2a focus: schema-validator + restore-packet command shell + mutual-exclusion.
|
||||
// M2b adds: codex-jsonl-parser + claude-transcript-parser + runtime-detect +
|
||||
// redaction + omitted-records (with Velocity round-trips).
|
||||
// M2c adds: packet-writer atomic emission + daemon route integration.
|
||||
//
|
||||
// TDD discipline (per memory feedback_tdd_scope): each test was authored to
|
||||
// fail first, then the implementation was written to green. The committed
|
||||
// state is the green/passing state.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
validateRestoreSummary,
|
||||
type ValidationResult,
|
||||
} from "../src/restore-packet/schema-validator.js";
|
||||
import { restorePacketCommand } from "../src/commands/restore-packet.js";
|
||||
|
||||
// Minimal valid summary fixture matching the M1 contract (§ 2.1) required-field
|
||||
// set verbatim. Tests compose against this baseline by mutating one field at
|
||||
// a time to drive the accept/reject matrix.
|
||||
function validSummary(): Record<string, unknown> {
|
||||
return {
|
||||
source_session_id: "velocity-driver@openrig-velocity",
|
||||
source_rig: "openrig-velocity",
|
||||
source_runtime: "claude-code",
|
||||
source_cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
target_rig: "openrig-velocity",
|
||||
target_runtime: "claude-code",
|
||||
target_workspace_root: "/Users/wrandom/code/projects/openrig-hub",
|
||||
default_target_repo: null,
|
||||
role_pointer: "rigs/openrig-velocity/state/velocity/driver-role.md",
|
||||
bounded_latest_transcript: {
|
||||
path: "transcript-latest.md",
|
||||
message_count: 87,
|
||||
bound: 120,
|
||||
},
|
||||
touched_files: {
|
||||
path: "touched-files.md",
|
||||
top_paths: [{ path: "packages/cli/src/commands/restore-packet.ts", count: 14 }],
|
||||
},
|
||||
durable_pointers: {
|
||||
queue_pointers: [],
|
||||
progress_pointers: [],
|
||||
field_note_pointers: [],
|
||||
artifact_pointers: [],
|
||||
},
|
||||
current_work_summary: "Working on M2a chunk of restore-packet vertical.",
|
||||
next_owner: "self",
|
||||
caveats: [],
|
||||
authority_boundaries: "Implement M2a only; do not touch M3+ surfaces.",
|
||||
omitted_classes: ["reasoning_records"],
|
||||
redaction_policy_id: "openrig-v0",
|
||||
source_trust_ranking: ["rig_whoami", "bounded_latest_transcript"],
|
||||
generator_version: "[email protected]",
|
||||
generated_at: "2026-05-01T23:30:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("M2 restore-packet schema-validator", () => {
|
||||
it("accepts a valid summary matching contract § 2.1", () => {
|
||||
const result: ValidationResult = validateRestoreSummary(validSummary());
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects when source_session_id is missing", () => {
|
||||
const summary = validSummary();
|
||||
delete (summary as Record<string, unknown>)["source_session_id"];
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /source_session_id|required/.test(e.field) || /source_session_id|required/i.test(e.rule))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when source_runtime is not one of the enum values", () => {
|
||||
const summary = validSummary();
|
||||
summary["source_runtime"] = "bash";
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /source_runtime/.test(e.field))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when source_cwd is not absolute", () => {
|
||||
const summary = validSummary();
|
||||
summary["source_cwd"] = "relative/path";
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /source_cwd/.test(e.field))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when bounded_latest_transcript.bound is not 120", () => {
|
||||
const summary = validSummary();
|
||||
(summary["bounded_latest_transcript"] as Record<string, unknown>)["bound"] = 100;
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /bound/.test(e.field))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when durable_pointers is missing required arrays", () => {
|
||||
const summary = validSummary();
|
||||
summary["durable_pointers"] = { queue_pointers: [] };
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
// Missing progress_pointers / field_note_pointers / artifact_pointers
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects when redaction_policy_id is not a known policy", () => {
|
||||
const summary = validSummary();
|
||||
summary["redaction_policy_id"] = "custom-policy";
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /redaction_policy_id/.test(e.field))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when source_trust_ranking is empty", () => {
|
||||
const summary = validSummary();
|
||||
summary["source_trust_ranking"] = [];
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /source_trust_ranking/.test(e.field))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when generated_at is not ISO-8601", () => {
|
||||
const summary = validSummary();
|
||||
summary["generated_at"] = "yesterday";
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /generated_at/.test(e.field))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when an unknown top-level field is present (additionalProperties: false)", () => {
|
||||
const summary = validSummary();
|
||||
(summary as Record<string, unknown>)["new_field"] = "leaked";
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => /new_field|additional/.test(e.field) || /additional/i.test(e.rule))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a summary with optional full_transcript present", () => {
|
||||
const summary = validSummary();
|
||||
summary["full_transcript"] = { path: "transcript.md", line_count: 4321 };
|
||||
const result = validateRestoreSummary(summary);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("M2 restore-packet CLI command shell + mutual-exclusion", () => {
|
||||
function runRestorePacket(argv: string[]): { exitCode: number | undefined; stderr: string[]; stdout: string[] } {
|
||||
const stderr: string[] = [];
|
||||
const stdout: string[] = [];
|
||||
const origExitCode = process.exitCode;
|
||||
const origConsoleLog = console.log;
|
||||
const origConsoleError = console.error;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => stdout.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => stderr.push(args.join(" "));
|
||||
try {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
// Route commander's writeErr (used for missing-required-option messages)
|
||||
// into our stderr capture so test assertions can match against it.
|
||||
program.configureOutput({ writeOut: (s) => stdout.push(s), writeErr: (s) => stderr.push(s) });
|
||||
const sub = restorePacketCommand();
|
||||
sub.exitOverride();
|
||||
sub.configureOutput({ writeOut: (s) => stdout.push(s), writeErr: (s) => stderr.push(s) });
|
||||
sub.commands.forEach((c) => {
|
||||
c.exitOverride();
|
||||
c.configureOutput({ writeOut: (s) => stdout.push(s), writeErr: (s) => stderr.push(s) });
|
||||
});
|
||||
program.addCommand(sub);
|
||||
try {
|
||||
program.parse(["node", "rig", ...argv]);
|
||||
} catch (err) {
|
||||
// commander throws on exitOverride + nonzero exit. Capture the
|
||||
// thrown CommanderError message into stderr so callers can match
|
||||
// against it (some commander error paths route through err.message
|
||||
// rather than configureOutput's writeErr).
|
||||
if (err instanceof Error && err.message) {
|
||||
stderr.push(err.message);
|
||||
}
|
||||
if (process.exitCode === undefined || process.exitCode === 0) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
console.log = origConsoleLog;
|
||||
console.error = origConsoleError;
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
return { exitCode, stderr, stdout };
|
||||
}
|
||||
|
||||
it("registers a `restore-packet` command with subcommands write / read / validate", () => {
|
||||
const cmd = restorePacketCommand();
|
||||
expect(cmd.name()).toBe("restore-packet");
|
||||
const subs = cmd.commands.map((c) => c.name()).sort();
|
||||
expect(subs).toEqual(["read", "validate", "write"]);
|
||||
});
|
||||
|
||||
it("write fails with explicit error when both --source-session AND --source-jsonl are supplied", () => {
|
||||
const { exitCode, stderr } = runRestorePacket([
|
||||
"restore-packet", "write",
|
||||
"--source-session", "fake@kernel",
|
||||
"--source-jsonl", "/tmp/fake.jsonl",
|
||||
"--target", "/tmp/out",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
const errStr = stderr.join("\n");
|
||||
expect(errStr).toMatch(/mutually exclusive|exactly one|both supplied/i);
|
||||
});
|
||||
|
||||
it("write fails with explicit error when neither --source-session nor --source-jsonl is supplied", () => {
|
||||
const { exitCode, stderr } = runRestorePacket([
|
||||
"restore-packet", "write",
|
||||
"--target", "/tmp/out",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
const errStr = stderr.join("\n");
|
||||
expect(errStr).toMatch(/--source-session|--source-jsonl|exactly one/i);
|
||||
});
|
||||
|
||||
it("write fails when --target is missing (commander requiredOption check)", () => {
|
||||
const { exitCode, stderr } = runRestorePacket([
|
||||
"restore-packet", "write",
|
||||
"--source-jsonl", "/tmp/fake.jsonl",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
const errStr = stderr.join("\n");
|
||||
expect(errStr).toMatch(/--target|required/i);
|
||||
});
|
||||
|
||||
it("read subcommand exists as M2a stub (full implementation in M3)", () => {
|
||||
const cmd = restorePacketCommand();
|
||||
const readCmd = cmd.commands.find((c) => c.name() === "read");
|
||||
expect(readCmd).toBeDefined();
|
||||
expect(readCmd!.description()).toMatch(/render|packet/i);
|
||||
});
|
||||
|
||||
it("validate subcommand exists as M2a stub (full implementation in M3)", () => {
|
||||
const cmd = restorePacketCommand();
|
||||
const validateCmd = cmd.commands.find((c) => c.name() === "validate");
|
||||
expect(validateCmd).toBeDefined();
|
||||
expect(validateCmd!.description()).toMatch(/validate|schema/i);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user