fix(cli): Restore-Packet M2a R2 — schema packaging fix; validator self-contained after raw tsc emit

Guard BLOCKED M2a at de2dbd4 with a build/packaging gap: schema-validator
read `restore-summary.schema.json` from disk via import.meta.url, but tsc
does not copy non-TS files into dist. Compiled validator hit ENOENT at
runtime in the guard's reproducer (raw `npx tsc --outDir <cache> --rootDir
src` + `node -e "import(...).then(m => m.validateRestoreSummary({}))"`).

R2 fix (combined approach 2 + approach 1 from guard verdict):

(1) src/restore-packet/schema-validator.ts: schema is now embedded as a
typed TS const RESTORE_SUMMARY_SCHEMA (verbatim from contract § 2.1 and
the canonical .json file). Validator uses the const directly; no
import.meta.url file read at runtime. tsc emits the const into compiled
JS, so `tsc` alone (no copy step) leaves a working validator.

(2) src/schemas/restore-summary.schema.json: kept as the canonical source
for downstream IDE / tooling consumption. Unchanged content.

(3) test/restore-packet.test.ts: added a drift-catcher test that loads
the JSON file and asserts `RESTORE_SUMMARY_SCHEMA` (the TS const) equals
it byte-for-byte. Prevents the two forms from diverging silently. Test
count 17 → 18.

(4) packages/cli/package.json: build script changed from `tsc` to
`tsc && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/`.
This is approach 1 from the guard verdict — keeps the JSON Schema
reachable in `dist/schemas/` for downstream tooling AFTER `npm run build`.
Note: the guard's reproducer uses raw `tsc` (not `npm run build`), so the
copy step is for distribution only; the runtime correctness comes from
approach 2 (the embedded TS const).

Re-verification (per guard's required-fix list):

- `npx vitest run test/restore-packet.test.ts` → 18/18 pass (M2a focused
  suite + drift-catcher; M2a's prior 17 still green plus the new test).
- `npx tsc --noEmit` → exit 0; no type errors.
- Guard's reproducer:
    rm -rf node_modules/.cache/openrig-cli-m2a-r2-build-check
    npx tsc --outDir node_modules/.cache/openrig-cli-m2a-r2-build-check --rootDir src
    node -e "import('node_modules/.cache/openrig-cli-m2a-r2-build-check/restore-packet/schema-validator.js').then((m) => { const r = m.validateRestoreSummary({}); console.log('valid:', r.valid, 'errors:', r.errors.length); }).catch(...)"
  → output: `valid: false errors: 21` (21 required-field violations for
  empty `{}` summary, exactly as expected from the schema's 21-required-
  field set). NO ENOENT.

Full CLI suite re-run (per Quality Lesson v8 — never silently downgrade):

- npx vitest run (full suite) → 658/662 pass.
- M2a baseline was 657/661; R2 adds the drift-catcher test, so totals
  shift by +1/+1. Failure pattern UNCHANGED:
  - test/chatroom.test.ts > Chatroom CLI > chatroom wait detects a newly
    arriving post-start message (10s timing flake)
  - test/config.test.ts > DaemonClient config integration > ConfigStore
    resolves non-default host/port from config file (env pollution from
    prior test runs)
  - test/preflight.test.ts > Preflight CLI > rig preflight --json prints
    structured result (host port 7433 in use; real daemon running)
  - test/preflight.test.ts > Preflight CLI > rig preflight prints check
    marks and exits 0 on pass (same host port 7433 issue)
  All four are pre-existing environmental flakes (host has a real daemon
  on port 7433; chatroom timing flake; env pollution between test runs).
  None of the four failures touch any file in this R2 commit. No
  regression introduced.

Scope (R2 stays narrow per dispatch):

- `restore-packet write` still a M2a shell that enforces mutual-exclusion
  + required-source behavior + returns the M2a deferred-implementation
  error. UNCHANGED.
- `restore-packet read` + `restore-packet validate` still return M3-pending
  errors. UNCHANGED.
- M2b parsers + redaction + omitted-records + packet-writer + daemon
  route extension + daemon route tests NOT touched.
- No openrig source changes outside the three R2 files.

Posture: full QA/testing window (per dispatch's required-posture line).

Source qitem: qitem-20260502005113-24bf9de8 (M2a R2 dispatch).
Guard verdict: rigs/openrig-velocity/state/orch/restore-packet-M2a-guard-checkpoint-block-2026-05-02.md.
This commit is contained in:
mvschwarz committed 2026-05-01 17:55:42 -07:00
1 parent de2dbd4f3a
commit 40d07241ab
3 files changed
+201 -13

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@
"scripts"
],
"scripts": {
"build": "tsc",
"build": "tsc && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/",
"postinstall": "node scripts/check-abi.mjs",
"prepublishOnly": "bash ../../scripts/build-package.sh",
"test": "vitest run",
@@ -11,12 +11,18 @@
// 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.
//
// M2a R2 packaging fix: the schema is embedded directly in this module as
// a typed TS const (RESTORE_SUMMARY_SCHEMA) rather than read from the
// sibling .json file at runtime. tsc emits the const into the compiled
// validator JS, so the validator works after `tsc` emit without any extra
// build-script copy step. The canonical JSON Schema file at
// `packages/cli/src/schemas/restore-summary.schema.json` remains the
// source of truth for downstream IDE / tooling consumption; a drift-catcher
// test in `test/restore-packet.test.ts` asserts the two stay byte-equivalent.
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;
@@ -30,22 +36,178 @@ export interface ValidationResult {
errors: ValidationError[];
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// RESTORE_SUMMARY_SCHEMA — verbatim mirror of
// `packages/cli/src/schemas/restore-summary.schema.json`. Keep these in
// lockstep; the drift-catcher test enforces equivalence.
export const RESTORE_SUMMARY_SCHEMA: Record<string, unknown> = {
$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,
};
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());
cachedValidator = ajv.compile(RESTORE_SUMMARY_SCHEMA);
return cachedValidator;
}
+26
View File
@@ -11,8 +11,12 @@
import { describe, it, expect } from "vitest";
import { Command } from "commander";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import {
validateRestoreSummary,
RESTORE_SUMMARY_SCHEMA,
type ValidationResult,
} from "../src/restore-packet/schema-validator.js";
import { restorePacketCommand } from "../src/commands/restore-packet.js";
@@ -144,6 +148,28 @@ describe("M2 restore-packet schema-validator", () => {
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(true);
});
// M2a R2 drift-catcher: the canonical JSON Schema file at
// src/schemas/restore-summary.schema.json and the embedded TS const
// RESTORE_SUMMARY_SCHEMA must stay byte-equivalent. Embedding the schema
// in the validator is the M2a R2 packaging fix (tsc emits the const into
// dist/restore-packet/schema-validator.js, so the validator works after
// raw `tsc` emit without any extra build-script copy step). The JSON file
// remains the canonical source for downstream tooling that reads JSON
// Schema directly. This test fails loudly if either form drifts.
it("M2a R2 drift-catcher: TS const RESTORE_SUMMARY_SCHEMA equals the canonical JSON schema file", () => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const schemaJsonPath = resolve(
__dirname,
"..",
"src",
"schemas",
"restore-summary.schema.json",
);
const jsonForm: unknown = JSON.parse(readFileSync(schemaJsonPath, "utf-8"));
expect(RESTORE_SUMMARY_SCHEMA).toEqual(jsonForm);
});
});
describe("M2 restore-packet CLI command shell + mutual-exclusion", () => {