mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat(cli): read immutable context reconstructions locally (#2207)
Adds rig context reconstruction inspect|view <revision>, which reads exact immutable v0 base records, replays deltas and follows pinned compositions from local stores, with no daemon call, skill install, context projection or remote fetch. The catalog project and work home are resolved through catalog-selection logic extracted from work-install, so both use the same selection. Inspect shows source manifests, purpose, depth, dates and approximate size; view keeps constraints, authority, gaps and transitive dependencies, shows actual omissions apart from authored coverage, and keeps inherited observation dates. A missing record or reference stays an explicit incomplete result. A store that exists on this machine is read at its original path; a store that moved needs a read-time mapping, and immutable pointers and revision IDs are never rewritten. Canonical hashes are Python-compatible for safe integer JSON values, and unsupported numeric representations are rejected. Every byte read is charged against a 32 MiB budget before it is decoded or parsed, and later reads stop once it is spent. docs: reconstructions.md and project-workspace.md describe the commands.
This commit is contained in:
1 parent
8edc076d81
commit
642be39e5b
10 files changed
+1118
-31
No files matched your search
@@ -7,6 +7,10 @@ without daemon-internal knowledge.
|
||||
This subtree is part of the wider [OpenRig Instance Layout](instance-layout.md).
|
||||
The instance initializer delegates these exact workspace bytes to this owner.
|
||||
|
||||
To inspect or read an exact reconstruction under a selected work home, use
|
||||
[`rig context reconstruction`](reconstructions.md). It shares catalog and work-home
|
||||
resolution with `context work-install` and performs no installation or projection.
|
||||
|
||||
## Default Shape
|
||||
|
||||
`rig config init-workspace` creates the default workspace at
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Read a reconstruction of selected work
|
||||
|
||||
A reconstruction records an author's working understanding, its evidence routes,
|
||||
constraints and gaps. `rig context reconstruction` reads an exact immutable
|
||||
`openrig.reconstruction/v0` revision locally. It does not start a daemon, fetch
|
||||
remote context, write records or transfer the author's authority.
|
||||
|
||||
Inspect the purpose, depth, author-declared coverage, observation dates, complete
|
||||
source manifest and approximate size before reading a view:
|
||||
|
||||
```bash
|
||||
rig context reconstruction inspect <full-sha256> --project example --mission task --slice 01-example
|
||||
rig context reconstruction view <full-sha256> --project example --mission task --slice 01-example --item decision --json
|
||||
```
|
||||
|
||||
Without `--store`, the store is `reconstructions/` under the selected project,
|
||||
mission or slice home. Project selection follows `context work-install`: an
|
||||
explicit catalog project, the only entry, the calling rig's association, the
|
||||
deepest project containing `--cwd`, or the only unclaimed entry. Ambiguity is an
|
||||
error. The project's configured `missions.root`, declared mission membership,
|
||||
slice directory names and slice frontmatter IDs are honored. A workspace
|
||||
without a catalog can use its own project home. No skill installation or context
|
||||
projection happens during this lookup.
|
||||
|
||||
Use `--store /absolute/local/store` for an imported store without resolving a
|
||||
project. Supplying work selectors as well still validates that work home.
|
||||
Revisions are full lowercase SHA-256 IDs; there is no implicit latest revision.
|
||||
|
||||
## Faithful views and gaps
|
||||
|
||||
Repeat `--item`, `--category` or `--tag` to select their union. A filtered view
|
||||
always retains authority, constraint and gap items, plus transitive item
|
||||
dependencies. It names omitted item IDs and uncited evidence routes, and keeps
|
||||
declared reconstruction coverage separate from actual view coverage. Inspect
|
||||
always provides the complete manifest; a filtered view provides cited routes.
|
||||
A full view also includes uncited registered routes.
|
||||
|
||||
Base records, deltas and pinned compositions retain inherited observation dates.
|
||||
A revision's creation date does not refresh those observations. Composition
|
||||
members do not imply an atomic capture. Authors are claimed attribution, and
|
||||
coverage is authored description rather than proof of runtime state or acceptance.
|
||||
|
||||
Unavailable records, missing selections, dependencies or citations make the
|
||||
result `complete: false` and set exit status 1. Available composition members
|
||||
remain visible with the missing member's exact store and revision. Unknown
|
||||
schemas, invalid content hashes and unsupported numeric representations are
|
||||
reported explicitly. The initial reader supports safe integer JSON numbers;
|
||||
floating-point/exponent and unsafe integer representations are rejected rather
|
||||
than assigned a different ID. Unicode keys follow v0's codepoint sorting.
|
||||
|
||||
## Relocate a store without changing its records
|
||||
|
||||
Parent and member pointers include absolute source-store paths in the hash.
|
||||
Do not edit those paths in immutable records. Copy their closure into local
|
||||
custody, then supply a separate JSON mapping:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sourceStore": "/original/work/reconstructions",
|
||||
"localStore": "/local/import/reconstructions",
|
||||
"sourceHost": "original-host"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```bash
|
||||
rig context reconstruction inspect <full-sha256> --store /local/import/reconstructions --store-map /local/map.json
|
||||
```
|
||||
|
||||
The mapping is read-time location information, not an identity registry or a
|
||||
remote fetch permission. It preserves source-host provenance. Since v0 pointers
|
||||
do not contain a host discriminator, duplicate mappings for the same source
|
||||
store are ambiguous and rejected. A store path that exists on this host can be
|
||||
read directly; a path that has moved needs a mapping to its local location.
|
||||
|
||||
## Bounds and byte checks
|
||||
|
||||
Reads are limited to 8 MiB per regular revision file, 32 MiB per closure, 64
|
||||
record references and 128 JSON nesting levels. Symlink revision files are not
|
||||
followed. Output defaults to 256 KiB; `--max-bytes` accepts 1 KiB through 8 MiB.
|
||||
Oversized output fails explicitly rather than silently truncating a purportedly
|
||||
complete view. Size estimates use UTF-8 bytes divided by four, not a model tokenizer.
|
||||
|
||||
`--check-sources` hashes declared local regular files within 128 files and 64 MiB
|
||||
total. It reports changed, unchanged, unavailable or unchecked bytes without
|
||||
executing source commands, fetching opaque routes or updating observations.
|
||||
Discovery declarations are shown but not scanned. Unchanged known files do not
|
||||
establish semantic freshness or detect new queue decisions. There is no queue
|
||||
discovery cursor, native writer or continuity-consumer replacement in this reader.
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Command } from "commander";
|
||||
import { join, resolve } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { resolveWorkContextHome } from "../lib/work-install.js";
|
||||
import { ReconstructionError, readReconstruction, readStoreMappings, reconstructionOutput } from "../lib/reconstruction-reader.js";
|
||||
|
||||
interface Options {
|
||||
store?: string; storeMap?: string; project?: string; mission?: string; slice?: string; cwd?: string;
|
||||
item: string[]; category: string[]; tag: string[]; checkSources?: boolean; maxBytes: string; json?: boolean;
|
||||
}
|
||||
const collect = (value: string, previous: string[]) => [...previous, value];
|
||||
|
||||
/** A local config/filesystem consumer. This command never starts or contacts a
|
||||
* daemon, invokes a helper, writes revisions, or takes over the selected work. */
|
||||
export function reconstructionCommand(): Command {
|
||||
const cmd = new Command("reconstruction")
|
||||
.description("Inspect or read an exact immutable reconstruction locally (read-only v0 consumer)");
|
||||
for (const mode of ["inspect", "view"] as const) {
|
||||
cmd.command(`${mode} <revision>`)
|
||||
.description(mode === "inspect" ? "Inspect provenance, dates, size and the complete source manifest" : "Read a faithful selected view with dependencies and explicit omissions")
|
||||
.option("--store <absolute-path>", "Explicit local v0/import store; default: selected work home/reconstructions")
|
||||
.option("--store-map <file>", "JSON array of {sourceStore, localStore, sourceHost?}; read-time mapping only")
|
||||
.option("--project <id>", "Catalog project, with the same inference as context work-install")
|
||||
.option("--mission <id>", "Mission in the selected project's configured missions root")
|
||||
.option("--slice <id>", "Slice under the selected mission")
|
||||
.option("--cwd <path>", "Working directory used for project inference")
|
||||
.option("--item <id>", "Include item (repeatable; selectors form a union)", collect, [])
|
||||
.option("--category <name>", "Include category (repeatable)", collect, [])
|
||||
.option("--tag <name>", "Include tag (repeatable)", collect, [])
|
||||
.option("--check-sources", "Hash declared local files within 128 files / 64 MiB; never refresh observation dates")
|
||||
.option("--max-bytes <bytes>", "Maximum complete output, 1024..8388608 UTF-8 bytes", "262144")
|
||||
.option("--json", "Structured output; inspect always uses JSON")
|
||||
.addHelpText("after", "\nSupports openrig.reconstruction/v0 with safe integer JSON numbers; other numeric representations are explicitly unsupported. No implicit remote reads. Context does not transfer authority.\n")
|
||||
.action((revision: string, opts: Options) => {
|
||||
try {
|
||||
let store = opts.store;
|
||||
let resolution: object = { mode: "explicit-store", project: null, home: null };
|
||||
if (store === undefined || opts.project !== undefined || opts.mission !== undefined || opts.slice !== undefined) {
|
||||
const config = new ConfigStore();
|
||||
const home = resolveWorkContextHome({
|
||||
workspaceRoot: String(config.resolveWithSource("workspace.root").value),
|
||||
catalogPath: String(config.resolveWithSource("workspace.catalog_path").value),
|
||||
cwd: resolve(opts.cwd ?? process.cwd()),
|
||||
...(readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME") ? { sessionName: readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")! } : {}),
|
||||
...(opts.project === undefined ? {} : { project: opts.project }),
|
||||
...(opts.mission === undefined ? {} : { mission: opts.mission }),
|
||||
...(opts.slice === undefined ? {} : { slice: opts.slice }),
|
||||
});
|
||||
if ("error" in home) throw new ReconstructionError(home.error.code, home.error.message);
|
||||
resolution = { mode: store === undefined ? "work-home" : "explicit-store", ...home };
|
||||
store ??= join(home.home, "reconstructions");
|
||||
}
|
||||
const result = readReconstruction({ store, revision,
|
||||
...(opts.storeMap ? { mappings: readStoreMappings(resolve(opts.storeMap)) } : {}),
|
||||
items: opts.item, categories: opts.category, tags: opts.tag, checkSources: opts.checkSources });
|
||||
const response = { ...result, resolution };
|
||||
console.log(reconstructionOutput(response, mode, Boolean(opts.json), Number(opts.maxBytes)));
|
||||
if (!result.complete) process.exitCode = 1;
|
||||
} catch (error) {
|
||||
const code = error instanceof ReconstructionError ? error.code : "reconstruction_unavailable";
|
||||
if (opts.json || mode === "inspect") console.log(JSON.stringify({ complete: false, error: { code, message: (error as Error).message } }, null, 2));
|
||||
else console.error(`${code}: ${(error as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import { realDeps } from "./daemon.js";
|
||||
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { reconstructionCommand } from "./context-reconstruction.js";
|
||||
|
||||
const contextRuntimeArg = enumArg(["claude-code", "claude", "codex"]);
|
||||
|
||||
@@ -252,6 +253,8 @@ Examples:
|
||||
rig context trace --rig factory --pod dev --seat dev-qa --name LEARNED.md
|
||||
`);
|
||||
|
||||
cmd.addCommand(reconstructionCommand());
|
||||
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, openSync, closeSync, fstatSync, lstatSync, readSync, existsSync } from "node:fs";
|
||||
import { isAbsolute, join, normalize } from "node:path";
|
||||
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
type RecordValue = { [key: string]: Json };
|
||||
export const RECONSTRUCTION_SCHEMA = "openrig.reconstruction/v0";
|
||||
const MAX_DOCUMENT = 8 * 1024 * 1024;
|
||||
const MAX_CLOSURE = 32 * 1024 * 1024;
|
||||
const PRESERVED = ["authority", "constraint", "gap"];
|
||||
|
||||
export class ReconstructionError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) { super(message); this.code = code; }
|
||||
}
|
||||
function requireValue(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new ReconstructionError("invalid_reconstruction", message);
|
||||
}
|
||||
function object(value: Json | undefined, label: string): RecordValue {
|
||||
requireValue(value !== null && typeof value === "object" && !Array.isArray(value), `${label} must be an object`);
|
||||
return value;
|
||||
}
|
||||
function list(value: Json | undefined, label: string): Json[] {
|
||||
requireValue(Array.isArray(value), `${label} must be an array`); return value;
|
||||
}
|
||||
function text(value: Json | undefined, label: string): string {
|
||||
requireValue(typeof value === "string" && value.trim(), `${label} must be a nonempty string`); return value;
|
||||
}
|
||||
function strings(value: Json | undefined, label: string): string[] {
|
||||
const values = list(value === undefined ? [] : value, label);
|
||||
requireValue(values.every(v => typeof v === "string"), `${label} must contain strings`); return values as string[];
|
||||
}
|
||||
function timestamp(value: Json | undefined, label: string, nullable = false): void {
|
||||
if (value === null && nullable) return;
|
||||
requireValue(typeof value === "string" && /(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value)), `${label} must have an ISO timestamp with timezone${nullable ? " or null" : ""}`);
|
||||
}
|
||||
function absolute(value: Json | undefined, label: string): string {
|
||||
const name = text(value, label); requireValue(isAbsolute(name), `${label} must be an absolute local path`); return normalize(name);
|
||||
}
|
||||
function revision(value: Json | undefined): string {
|
||||
const id = text(value, "revision"); requireValue(/^[a-f0-9]{64}$/.test(id), "revision must be a full lowercase SHA256 ID"); return id;
|
||||
}
|
||||
function validUnicode(value: string): void {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const unit = value.charCodeAt(i);
|
||||
if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
const next = value.charCodeAt(++i);
|
||||
requireValue(next >= 0xdc00 && next <= 0xdfff, "unpaired Unicode surrogate is unsupported by v0 UTF-8 hashing");
|
||||
} else requireValue(unit < 0xdc00 || unit > 0xdfff, "unpaired Unicode surrogate is unsupported by v0 UTF-8 hashing");
|
||||
}
|
||||
}
|
||||
|
||||
/** v0 is Python sorted compact JSON, not JS property order. Reject numeric
|
||||
* representations we cannot reproduce, rather than silently changing an ID. */
|
||||
export function parseReconstructionJson(input: string): Json {
|
||||
let at = 0;
|
||||
const space = () => { while (/[ \n\r\t]/.test(input[at] ?? "x")) at++; };
|
||||
const quoted = (): string => {
|
||||
const start = at++;
|
||||
while (at < input.length) {
|
||||
const char = input[at++];
|
||||
if (char === "\\") at++;
|
||||
else if (char === '"') {
|
||||
const result: unknown = JSON.parse(input.slice(start, at));
|
||||
requireValue(typeof result === "string", "invalid JSON string"); validUnicode(result); return result;
|
||||
}
|
||||
}
|
||||
throw new ReconstructionError("invalid_reconstruction", "unterminated JSON string");
|
||||
};
|
||||
const value = (depth: number): Json => {
|
||||
requireValue(depth <= 128, "JSON nesting exceeds 128"); space();
|
||||
if (input[at] === '"') return quoted();
|
||||
if (input[at] === "{") {
|
||||
at++; space(); const result: RecordValue = Object.create(null) as RecordValue;
|
||||
if (input[at] !== "}") while (true) {
|
||||
requireValue(input[at] === '"', "expected JSON object key"); const key = quoted(); space();
|
||||
requireValue(!Object.hasOwn(result, key), `duplicate JSON key: ${key}`);
|
||||
requireValue(input[at++] === ":", "expected JSON colon"); result[key] = value(depth + 1); space();
|
||||
if (input[at] !== ",") break; at++; space();
|
||||
}
|
||||
requireValue(input[at++] === "}", "expected JSON object end"); return result;
|
||||
}
|
||||
if (input[at] === "[") {
|
||||
at++; space(); const result: Json[] = [];
|
||||
if (input[at] !== "]") while (true) {
|
||||
result.push(value(depth + 1)); space(); if (input[at] !== ",") break; at++;
|
||||
}
|
||||
requireValue(input[at++] === "]", "expected JSON array end"); return result;
|
||||
}
|
||||
for (const [token, result] of [["true", true], ["false", false], ["null", null]] as const) {
|
||||
if (input.startsWith(token, at)) { at += token.length; return result; }
|
||||
}
|
||||
const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(input.slice(at))?.[0];
|
||||
requireValue(number !== undefined, "expected JSON value");
|
||||
if (/[.eE]/.test(number) || !Number.isSafeInteger(Number(number))) {
|
||||
throw new ReconstructionError("unsupported_v0_number", "v0 reader supports safe integer JSON numbers only; floating/exponent and unsafe integers require an explicit compatible reader, never an ID rewrite");
|
||||
}
|
||||
at += number.length; return Number(number);
|
||||
};
|
||||
try {
|
||||
const parsed = value(0); space(); requireValue(at === input.length, "trailing JSON input"); return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof ReconstructionError) throw error;
|
||||
throw new ReconstructionError("invalid_reconstruction", `invalid JSON: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function codepointOrder(a: string, b: string): number {
|
||||
const aa = Array.from(a), bb = Array.from(b);
|
||||
for (let i = 0; i < Math.min(aa.length, bb.length); i++) {
|
||||
const difference = aa[i]!.codePointAt(0)! - bb[i]!.codePointAt(0)!; if (difference) return difference;
|
||||
}
|
||||
return aa.length - bb.length;
|
||||
}
|
||||
export function canonicalV0(value: Json): string {
|
||||
if (value === null || typeof value !== "object") {
|
||||
if (typeof value === "number") requireValue(Number.isSafeInteger(value), "unsupported v0 number");
|
||||
if (typeof value === "string") validUnicode(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalV0).join(",")}]`;
|
||||
return `{${Object.keys(value).sort(codepointOrder).map(key => `${canonicalV0(key)}:${canonicalV0(value[key]!)}`).join(",")}}`;
|
||||
}
|
||||
const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
/** Regular-file, no-follow, bounded read; no source commands are executed. */
|
||||
function readRegular(file: string, limit: number, onRead?: (bytes: number) => void): Buffer {
|
||||
const initial = lstatSync(file);
|
||||
requireValue(initial.isFile() && !initial.isSymbolicLink(), `not a regular file: ${file}`);
|
||||
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
const before = fstatSync(fd);
|
||||
requireValue(before.isFile() && before.dev === initial.dev && before.ino === initial.ino && before.size <= limit, `file identity or byte bound failed: ${file}`);
|
||||
const chunks: Buffer[] = []; let bytes = 0;
|
||||
while (bytes < limit) {
|
||||
const buffer = Buffer.alloc(Math.min(65536, limit - bytes));
|
||||
const count = readSync(fd, buffer); if (!count) break;
|
||||
bytes += count; onRead?.(count); chunks.push(buffer.subarray(0, count));
|
||||
}
|
||||
const after = fstatSync(fd), current = lstatSync(file);
|
||||
requireValue(before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs
|
||||
&& after.dev === current.dev && after.ino === current.ino, `file changed during read: ${file}`);
|
||||
return Buffer.concat(chunks);
|
||||
} finally { closeSync(fd); }
|
||||
}
|
||||
function jsonFile(file: string, remainingBytes = MAX_DOCUMENT, onRead?: (bytes: number) => void): { value: Json; bytes: number } {
|
||||
const bytes = readRegular(file, Math.min(MAX_DOCUMENT, remainingBytes), onRead);
|
||||
return { value: parseReconstructionJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), bytes: bytes.length };
|
||||
}
|
||||
|
||||
export interface StoreMapping { sourceStore: string; localStore: string; sourceHost?: string }
|
||||
export function readStoreMappings(file: string): StoreMapping[] {
|
||||
return list(jsonFile(file).value, "store mappings").map(value => {
|
||||
const entry = object(value, "mapping");
|
||||
return { sourceStore: absolute(entry.sourceStore, "sourceStore"), localStore: absolute(entry.localStore, "localStore"),
|
||||
...(entry.sourceHost === undefined ? {} : { sourceHost: text(entry.sourceHost, "sourceHost") }) };
|
||||
});
|
||||
}
|
||||
interface Pointer { store: string; revision: string }
|
||||
function pointer(value: Json | undefined): Pointer {
|
||||
const v = object(value, "pointer"); return { store: absolute(v.store, "pointer.store"), revision: revision(v.revision) };
|
||||
}
|
||||
function records(value: Json | undefined, kind: "items" | "sources"): RecordValue[] {
|
||||
const seen = new Set<string>();
|
||||
return list(value === undefined ? [] : value, kind).map(v => {
|
||||
const r = object(v, kind), id = text(r.id, `${kind}.id`); requireValue(!seen.has(id), `duplicate ${kind} ID: ${id}`); seen.add(id);
|
||||
timestamp(r.observed_at, `${kind}.observed_at`, true);
|
||||
if (kind === "items") {
|
||||
text(r.category, "item.category"); text(r.text, "item.text");
|
||||
if (r.status !== undefined) text(r.status, "item.status");
|
||||
for (const name of ["sources", "depends_on", "tags"]) strings(r[name], name);
|
||||
requireValue(["source-belief", "assessment", "observed", "reported"].includes(String(r.perspective ?? "assessment")), "invalid item perspective");
|
||||
} else {
|
||||
requireValue(r.path !== undefined || r.address !== undefined, "source requires path or address");
|
||||
if (r.path !== undefined) absolute(r.path, "source.path");
|
||||
if (r.address !== undefined) text(r.address, "source.address");
|
||||
if (r.sha256 !== undefined && r.sha256 !== null) revision(r.sha256);
|
||||
}
|
||||
return r;
|
||||
});
|
||||
}
|
||||
function discovery(value: Json | undefined): Json[] {
|
||||
const roots = list(value === undefined ? [] : value, "discovery"), ids = new Set<string>();
|
||||
for (const value of roots) {
|
||||
const root = object(value, "discovery entry"), id = text(root.id, "discovery.id");
|
||||
requireValue(!ids.has(id), "duplicate discovery ID"); ids.add(id);
|
||||
absolute(root.path, "discovery.path");
|
||||
for (const name of strings(root.known_paths, "discovery.known_paths")) {
|
||||
requireValue(!isAbsolute(name) && !name.split("/").includes(".."), "discovery known_paths must stay under root");
|
||||
}
|
||||
const entries = root.max_entries ?? 1000, depth = root.max_depth ?? 8;
|
||||
requireValue(typeof entries === "number" && Number.isInteger(entries) && entries >= 1 && entries <= 10000, "discovery.max_entries must be 1..10000");
|
||||
requireValue(typeof depth === "number" && Number.isInteger(depth) && depth >= 0 && depth <= 32, "discovery.max_depth must be 0..32");
|
||||
timestamp(root.observed_at ?? null, "discovery.observed_at", true);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
function validate(doc: RecordValue): void {
|
||||
if (doc.schema !== RECONSTRUCTION_SCHEMA) throw new ReconstructionError("unsupported_schema", `unsupported reconstruction schema: ${String(doc.schema)}`);
|
||||
requireValue(["base", "delta", "composition"].includes(String(doc.kind)), "invalid reconstruction kind");
|
||||
for (const key of ["subject", "purpose", "author"]) text(doc[key], key);
|
||||
timestamp(doc.created_at, "created_at");
|
||||
requireValue(["orientation", "working", "continuity", "full-domain"].includes(String(doc.depth)), "invalid reconstruction depth");
|
||||
for (const key of ["items", "upsert_items"]) if (doc[key] !== undefined) records(doc[key], "items");
|
||||
for (const key of ["sources", "upsert_sources"]) if (doc[key] !== undefined) records(doc[key], "sources");
|
||||
if (doc.coverage !== undefined) {
|
||||
const coverage = object(doc.coverage, "coverage"); strings(coverage.included, "coverage.included"); strings(coverage.excluded, "coverage.excluded");
|
||||
}
|
||||
strings(doc.unknowns, "unknowns"); discovery(doc.discovery);
|
||||
for (const key of ["remove_items", "remove_sources"]) {
|
||||
const ids = strings(doc[key], key); requireValue(new Set(ids).size === ids.length, `duplicate ${key}`);
|
||||
}
|
||||
if (doc.kind === "delta") {
|
||||
pointer(doc.parent);
|
||||
requireValue(!["items", "sources", "members"].some(key => key in doc), "delta uses upsert/remove fields; members remain pinned");
|
||||
} else requireValue(!Object.keys(doc).some(key => key.startsWith("upsert_") || key.startsWith("remove_")), "base/composition uses items and sources, not delta fields");
|
||||
if (doc.kind === "composition") {
|
||||
const members = list(doc.members, "members"); requireValue(members.length > 0 && members.length <= 64, "composition requires 1..64 members"); members.forEach(pointer);
|
||||
}
|
||||
}
|
||||
interface Node {
|
||||
document: RecordValue; sourceStore: string; localStore: string; items: RecordValue[]; sources: RecordValue[];
|
||||
coverage: Json; unknowns: string[]; discovery: Json[]; members: Node[]; lineage: Pointer[];
|
||||
}
|
||||
interface Issue { code: string; message: string; store?: string; revision?: string; subject?: string }
|
||||
export interface ReconstructionOptions {
|
||||
store: string; revision: string; mappings?: StoreMapping[];
|
||||
items?: string[]; categories?: string[]; tags?: string[]; checkSources?: boolean;
|
||||
}
|
||||
const flatten = (node: Node): Node[] => [node, ...node.members.flatMap(flatten)];
|
||||
|
||||
export function readReconstruction(options: ReconstructionOptions) {
|
||||
const rootStore = absolute(options.store, "store"), rootRevision = revision(options.revision);
|
||||
const mappings = new Map<string, StoreMapping>();
|
||||
for (const entry of options.mappings ?? []) {
|
||||
const source = absolute(entry.sourceStore, "sourceStore");
|
||||
requireValue(!mappings.has(source), "ambiguous duplicate source-store mapping; v0 pointers have no host discriminator");
|
||||
mappings.set(source, { ...entry, sourceStore: source, localStore: absolute(entry.localStore, "localStore") });
|
||||
}
|
||||
const issues: Issue[] = [], loaded: Array<{ revision: string; sourceStore: string; localStore: string; fileBytes: number }> = [];
|
||||
let totalBytes = 0, visited = 0;
|
||||
let exhausted = false;
|
||||
const materialize = (store: string, id: string, stack: string[]): Node | undefined => {
|
||||
if (exhausted) return undefined;
|
||||
const sourceStore = absolute(store, "pointer.store"), key = `${sourceStore}\0${id}`;
|
||||
try {
|
||||
if (stack.length >= 64 || ++visited > 64 || totalBytes >= MAX_CLOSURE) {
|
||||
exhausted = true;
|
||||
throw new ReconstructionError("closure_limit", "reconstruction closure exceeds 64 references or 32 MiB");
|
||||
}
|
||||
requireValue(!stack.includes(key), "reconstruction reference cycle");
|
||||
const mapping = mappings.get(sourceStore);
|
||||
if (!mapping && sourceStore !== rootStore && !existsSync(sourceStore)) throw new ReconstructionError("unmapped_external_store", `store ${sourceStore} is not available here; supply an explicit store mapping`);
|
||||
const localStore = mapping?.localStore ?? sourceStore;
|
||||
// Charge positive reads even when decoding, parsing or a late identity check fails.
|
||||
const file = jsonFile(join(localStore, `${revision(id)}.json`), MAX_CLOSURE - totalBytes, count => { totalBytes += count; });
|
||||
const doc = object(file.value, "revision"); requireValue(doc.id === id, "stored ID does not match requested revision");
|
||||
const { id: ignored, ...body } = doc; void ignored;
|
||||
requireValue(hash(canonicalV0(body)) === id, "revision content hash mismatch"); validate(doc);
|
||||
loaded.push({ revision: id, sourceStore, localStore, fileBytes: file.bytes });
|
||||
const trail = [...stack, key]; let result: Node;
|
||||
if (doc.kind === "delta") {
|
||||
const parent = pointer(doc.parent), inherited = materialize(parent.store, parent.revision, trail);
|
||||
if (!inherited) return undefined;
|
||||
requireValue(inherited.document.subject === doc.subject, "delta cannot change subject identity");
|
||||
result = { ...inherited, members: [...inherited.members], lineage: [...inherited.lineage, { store: sourceStore, revision: id }] };
|
||||
for (const collection of ["items", "sources"] as const) {
|
||||
const values = new Map(inherited[collection].map(v => [String(v.id), v]));
|
||||
const removed = strings(doc[`remove_${collection}`], `remove_${collection}`);
|
||||
const updates = records(doc[`upsert_${collection}`], collection);
|
||||
for (const remove of removed) {
|
||||
requireValue(values.has(remove) && !updates.some(v => v.id === remove), `invalid removal/upsert: ${remove}`); values.delete(remove);
|
||||
}
|
||||
for (const v of updates) values.set(String(v.id), v);
|
||||
result[collection] = [...values.values()];
|
||||
}
|
||||
if (doc.coverage !== undefined) result.coverage = doc.coverage;
|
||||
if (doc.unknowns !== undefined) result.unknowns = strings(doc.unknowns, "unknowns");
|
||||
if (doc.discovery !== undefined) result.discovery = list(doc.discovery, "discovery");
|
||||
} else {
|
||||
result = { document: doc, sourceStore, localStore, items: records(doc.items, "items"), sources: records(doc.sources, "sources"),
|
||||
coverage: doc.coverage ?? { included: [], excluded: [] }, unknowns: strings(doc.unknowns, "unknowns"), discovery: discovery(doc.discovery),
|
||||
members: [], lineage: [{ store: sourceStore, revision: id }] };
|
||||
for (const member of list(doc.members === undefined ? [] : doc.members, "members")) {
|
||||
const p = pointer(member), node = materialize(p.store, p.revision, trail); if (node) result.members.push(node);
|
||||
}
|
||||
}
|
||||
return { ...result, document: doc, sourceStore, localStore };
|
||||
} catch (error) {
|
||||
issues.push({ code: error instanceof ReconstructionError ? error.code : "revision_unavailable", message: (error as Error).message, store: sourceStore, revision: id });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
let root = materialize(rootStore, rootRevision, []);
|
||||
const seenSubjects = new Set<string>();
|
||||
if (root) for (const node of flatten(root)) {
|
||||
const subject = String(node.document.subject);
|
||||
if (seenSubjects.has(subject)) { issues.push({ code: "duplicate_subject", message: `composition repeats subject ${subject}` }); root = undefined; break; }
|
||||
seenSubjects.add(subject);
|
||||
}
|
||||
const nodes = root ? flatten(root) : [], allIds = new Set(nodes.flatMap(node => node.items.map(v => String(v.id))));
|
||||
const filtered = Boolean(options.items?.length || options.categories?.length || options.tags?.length);
|
||||
for (const id of options.items ?? []) if (!allIds.has(id)) issues.push({ code: "missing_item", message: `selected item is missing: ${id}` });
|
||||
const selected = nodes.map(node => {
|
||||
const byId = new Map(node.items.map(v => [String(v.id), v]));
|
||||
const ids = new Set(node.items.filter(v => !filtered || options.items?.includes(String(v.id)) || options.categories?.includes(String(v.category))
|
||||
|| strings(v.tags, "tags").some(tag => options.tags?.includes(tag)) || PRESERVED.includes(String(v.category))).map(v => String(v.id)));
|
||||
const pending = [...ids];
|
||||
while (pending.length) for (const dependency of strings(byId.get(pending.pop()!)!.depends_on, "depends_on")) {
|
||||
if (!byId.has(dependency)) issues.push({ code: "missing_dependency", message: `missing dependency: ${dependency}`, subject: String(node.document.subject) });
|
||||
else if (!ids.has(dependency)) { ids.add(dependency); pending.push(dependency); }
|
||||
}
|
||||
const items = node.items.filter(v => ids.has(String(v.id))), cited = new Set(items.flatMap(v => strings(v.sources, "sources")));
|
||||
const sources = new Map(node.sources.map(v => [String(v.id), v]));
|
||||
for (const id of cited) if (!sources.has(id)) issues.push({ code: "missing_citation", message: `missing source: ${id}`, subject: String(node.document.subject) });
|
||||
return { node, items, cited, ids };
|
||||
});
|
||||
let sourceBytes = 0, sourceCount = 0;
|
||||
const sourceChecks = options.checkSources ? nodes.flatMap(node => node.sources.map(source => {
|
||||
const base = { subject: node.document.subject, id: source.id, observedAt: source.observed_at };
|
||||
if (source.path === undefined) return { ...base, status: "unchecked", reason: "opaque/nonlocal route; not fetched" };
|
||||
try {
|
||||
requireValue(++sourceCount <= 128 && sourceBytes < 64 * 1024 * 1024, "source checking budget exhausted (128 files / 64 MiB total)");
|
||||
const bytes = readRegular(String(source.path), 64 * 1024 * 1024 - sourceBytes, count => { sourceBytes += count; });
|
||||
const actual = hash(bytes);
|
||||
return { ...base, sha256: actual, status: source.sha256 ? (actual === source.sha256 ? "unchanged" : "changed") : "unchecked", reason: "byte comparison only; observation dates and semantic freshness are not refreshed" };
|
||||
} catch (error) { return { ...base, status: "unavailable", reason: (error as Error).message }; }
|
||||
})) : undefined;
|
||||
const materializedBytes = Buffer.byteLength(JSON.stringify(root ?? null));
|
||||
return {
|
||||
presentationSchema: "openrig.reconstruction-reader/v1", storedSchema: RECONSTRUCTION_SCHEMA,
|
||||
requestedStore: rootStore, requestedRevision: rootRevision, complete: issues.length === 0, issues,
|
||||
completenessScope: "record closure and selected item/citation references; not source freshness",
|
||||
caveat: "Context is evidence for understanding, not authority, competence, acceptance or current runtime state. Authors are claimed attribution. No discovery cursor or implicit remote reads.",
|
||||
selectors: { mode: filtered ? "filtered" : "full", items: options.items ?? [], categories: options.categories ?? [], tags: options.tags ?? [], combination: "union", alwaysIncludeCategories: PRESERVED, includeTransitiveDependencies: true },
|
||||
mappings: [...mappings.values()], records: loaded, closureBytesRead: totalBytes, atomicGroupCapture: false,
|
||||
size: { materializedJsonUtf8Bytes: materializedBytes, approximateTokens: Math.ceil(materializedBytes / 4),
|
||||
estimateMethod: "materialized reader JSON UTF-8 bytes / 4; rough, not a model tokenizer" },
|
||||
nodes: selected.map(({ node, items, cited, ids }) => ({
|
||||
subject: node.document.subject, revision: node.document.id, kind: node.document.kind, author: node.document.author,
|
||||
purpose: node.document.purpose, depth: node.document.depth, createdAt: node.document.created_at,
|
||||
sourceStore: node.sourceStore, localStore: node.localStore, lineage: node.lineage,
|
||||
declaredCoverage: node.coverage, unknowns: node.unknowns, discovery: node.discovery,
|
||||
sourceManifest: node.sources, itemObservations: node.items.map(v => ({ id: v.id, observedAt: v.observed_at })),
|
||||
items, viewSources: node.sources.filter(v => !filtered || cited.has(String(v.id))),
|
||||
viewCoverage: { totalItems: node.items.length, includedIds: items.map(v => v.id), omittedIds: node.items.filter(v => !ids.has(String(v.id))).map(v => v.id),
|
||||
registeredSources: node.sources.length, citedSourceIds: [...cited], omittedRouteIds: filtered ? node.sources.filter(v => !cited.has(String(v.id))).map(v => v.id) : [] },
|
||||
})), ...(sourceChecks ? { sourceChecks } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type ReconstructionRead = ReturnType<typeof readReconstruction>;
|
||||
export function reconstructionOutput(result: ReconstructionRead, mode: "inspect" | "view", json: boolean, maxBytes = 256 * 1024): string {
|
||||
requireValue(Number.isInteger(maxBytes) && maxBytes >= 1024 && maxBytes <= MAX_DOCUMENT, "max-bytes must be 1024..8388608");
|
||||
// Inspect never delivers item bodies; view carries its actual omissions and
|
||||
// complete-manifest route rather than presenting full coverage as selected.
|
||||
const data = { ...result, nodes: result.nodes.map(node => mode === "inspect" ? { ...node, items: undefined, viewSources: undefined } : { ...node, sourceManifest: undefined }) };
|
||||
let output: string;
|
||||
if (json || mode === "inspect") output = JSON.stringify(data, null, 2);
|
||||
else {
|
||||
const lines = ["# Reconstruction view", "", result.caveat, `Complete: ${result.complete} (${result.completenessScope})`, `Applied selectors: ${JSON.stringify(result.selectors)}`];
|
||||
for (const issue of result.issues) lines.push(`UNAVAILABLE: ${issue.code}: ${issue.message}`);
|
||||
for (const node of result.nodes) {
|
||||
lines.push("", `## ${String(node.subject)}`, `Revision: ${String(node.revision)}`, `Author (claimed): ${String(node.author)}`, `Purpose: ${String(node.purpose)}`, `Depth: ${String(node.depth)}`,
|
||||
`Source store: ${node.sourceStore}; local read store: ${node.localStore}`,
|
||||
`Created: ${String(node.createdAt)} (not inherited-fact verification time)`, `Author-declared coverage: ${JSON.stringify(node.declaredCoverage)}`, `Selected view coverage: ${JSON.stringify(node.viewCoverage)}`,
|
||||
"Composition members retain independent observation dates; no atomic capture is claimed.");
|
||||
for (const unknown of node.unknowns) lines.push(`Unknown: ${unknown}`);
|
||||
for (const item of node.items) lines.push("", `### ${String(item.id)} [${String(item.category)}]`, String(item.text),
|
||||
`Perspective: ${String(item.perspective ?? "assessment")}; observed: ${String(item.observed_at)}; status: ${String(item.status ?? "unspecified")}`,
|
||||
`Sources: ${JSON.stringify(item.sources ?? [])}; dependencies: ${JSON.stringify(item.depends_on ?? [])}`);
|
||||
lines.push("", "Evidence routes:");
|
||||
for (const source of node.viewSources) lines.push(JSON.stringify(source));
|
||||
if (node.viewCoverage.omittedRouteIds.length) lines.push("Uncited routes omitted; inspect this exact revision for its complete source manifest.");
|
||||
}
|
||||
if (result.sourceChecks) lines.push("Source byte checks (not semantic reverification):", JSON.stringify(result.sourceChecks));
|
||||
output = lines.join("\n");
|
||||
}
|
||||
const bytes = Buffer.byteLength(output);
|
||||
if (bytes > maxBytes) throw new ReconstructionError("output_limit", `output is ${bytes} UTF-8 bytes; max-bytes is ${maxBytes}. Select a narrower view or explicitly raise the bound; no complete output was delivered.`);
|
||||
return output;
|
||||
}
|
||||
@@ -215,45 +215,22 @@ function resolveExplicitSlice(
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
export function resolveWorkPosition(opts: {
|
||||
export interface WorkProjectOptions {
|
||||
workspaceRoot: string;
|
||||
catalogPath?: string;
|
||||
contextRoot: string;
|
||||
systemWorldSelection: string;
|
||||
systemWorldSource: SystemWorldSource;
|
||||
project?: string;
|
||||
mission?: string;
|
||||
slice?: string;
|
||||
/** Working directory for selection step 4 (the CLI passes --cwd or the process cwd). */
|
||||
cwd?: string;
|
||||
/** Calling seat's canonical session, for selection step 3; absent in a plain shell. */
|
||||
sessionName?: string;
|
||||
}): WorkInstallResult {
|
||||
}
|
||||
|
||||
/** Catalog/home resolution shared by read-only work consumers and work-install.
|
||||
* No System World, skill selection or projection is performed here. */
|
||||
export function resolveWorkProject(opts: WorkProjectOptions, warnings: string[]) {
|
||||
if (opts.project !== undefined && !SEGMENT.test(opts.project)) {
|
||||
return failure("invalid_project", "project must be a single bounded segment");
|
||||
}
|
||||
if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) {
|
||||
return failure("invalid_mission", "mission must be a single bounded segment");
|
||||
}
|
||||
if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) {
|
||||
return failure("invalid_slice", "slice must be a single bounded segment");
|
||||
}
|
||||
if (opts.slice !== undefined && opts.mission === undefined) {
|
||||
return failure("mission_required", "slice requires an exact mission selection");
|
||||
}
|
||||
|
||||
const workspaceRoot = canonicalExisting(opts.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const systemWorld = resolveSystemWorld({
|
||||
contextRoot: opts.contextRoot,
|
||||
selection: opts.systemWorldSelection,
|
||||
source: opts.systemWorldSource,
|
||||
});
|
||||
if (!systemWorld.ok) return failure(systemWorld.error.code, systemWorld.error.message);
|
||||
if (!workspaceRoot) return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`);
|
||||
const catalogPath = resolve(opts.catalogPath ?? join(workspaceRoot, "workspace.yaml"));
|
||||
let projectId: string | null = null;
|
||||
let projectRoot = workspaceRoot;
|
||||
@@ -303,6 +280,85 @@ export function resolveWorkPosition(opts: {
|
||||
return failure("missions_root_escape", "project.yaml missions.root resolves outside the selected project root");
|
||||
}
|
||||
|
||||
return { workspaceRoot, projectId, projectRoot, selectedBy, projectManifest, missionsRoot };
|
||||
}
|
||||
|
||||
/** A context home is a location, not task ownership or runtime authority. */
|
||||
export function resolveWorkContextHome(opts: WorkProjectOptions & { mission?: string; slice?: string }) {
|
||||
const warnings: string[] = [];
|
||||
if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) return failure("invalid_mission", "mission must be a single bounded segment");
|
||||
if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) return failure("invalid_slice", "slice must be a single bounded segment");
|
||||
if (opts.slice !== undefined && opts.mission === undefined) return failure("mission_required", "slice requires an exact mission selection");
|
||||
const project = resolveWorkProject(opts, warnings);
|
||||
if ("error" in project) return project;
|
||||
let home = project.projectRoot;
|
||||
if (opts.mission !== undefined) {
|
||||
const nominal = join(project.missionsRoot, opts.mission);
|
||||
const mission = canonicalExisting(nominal);
|
||||
if (!mission) return failure("mission_not_found", `mission '${opts.mission}' does not exist in the selected project`);
|
||||
if (!inside(project.projectRoot, mission)) return failure("mission_root_escape", "mission resolves outside the selected project root");
|
||||
home = mission;
|
||||
if (opts.slice !== undefined) {
|
||||
let declared: Set<string> | undefined;
|
||||
const manifestPath = join(mission, "mission.yaml");
|
||||
if (existsSync(manifestPath)) {
|
||||
const parsed = readYaml(manifestPath), composition = parsed.value?.["composition"];
|
||||
if (parsed.value && isRecord(composition) && "slices" in composition) {
|
||||
try { declared = new Set(validateMissionComposition(parsed.value, manifestPath).map(member => dirname(member.path))); }
|
||||
catch (error) { return failure("mission_composition_invalid", (error as Error).message); }
|
||||
} else if (!parsed.value) warnings.push(`${parsed.error}; kept conventional slice addresses`);
|
||||
}
|
||||
const slice = resolveExplicitSlice(mission, opts.slice, declared);
|
||||
if ("error" in slice) return slice;
|
||||
home = slice.root;
|
||||
}
|
||||
}
|
||||
return { projectId: project.projectId, projectRoot: project.projectRoot, selectedBy: project.selectedBy, home, warnings };
|
||||
}
|
||||
|
||||
export function resolveWorkPosition(opts: {
|
||||
workspaceRoot: string;
|
||||
catalogPath?: string;
|
||||
contextRoot: string;
|
||||
systemWorldSelection: string;
|
||||
systemWorldSource: SystemWorldSource;
|
||||
project?: string;
|
||||
mission?: string;
|
||||
slice?: string;
|
||||
/** Working directory for selection step 4 (the CLI passes --cwd or the process cwd). */
|
||||
cwd?: string;
|
||||
/** Calling seat's canonical session, for selection step 3; absent in a plain shell. */
|
||||
sessionName?: string;
|
||||
}): WorkInstallResult {
|
||||
if (opts.project !== undefined && !SEGMENT.test(opts.project)) {
|
||||
return failure("invalid_project", "project must be a single bounded segment");
|
||||
}
|
||||
if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) {
|
||||
return failure("invalid_mission", "mission must be a single bounded segment");
|
||||
}
|
||||
if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) {
|
||||
return failure("invalid_slice", "slice must be a single bounded segment");
|
||||
}
|
||||
if (opts.slice !== undefined && opts.mission === undefined) {
|
||||
return failure("mission_required", "slice requires an exact mission selection");
|
||||
}
|
||||
|
||||
const workspaceRoot = canonicalExisting(opts.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const systemWorld = resolveSystemWorld({
|
||||
contextRoot: opts.contextRoot,
|
||||
selection: opts.systemWorldSelection,
|
||||
source: opts.systemWorldSource,
|
||||
});
|
||||
if (!systemWorld.ok) return failure(systemWorld.error.code, systemWorld.error.message);
|
||||
const project = resolveWorkProject({ ...opts, workspaceRoot }, warnings);
|
||||
if ("error" in project) return project;
|
||||
const { projectId, projectRoot, selectedBy, projectManifest, missionsRoot } = project;
|
||||
|
||||
let projectIntent = "SPEC.md";
|
||||
let projectIntentSource: WorkInstallSource = "default";
|
||||
let projectContext: string[] = [];
|
||||
|
||||
@@ -24,7 +24,9 @@ const VIEWER_DESCRIPTION_FRAGMENT = "context-usage across running agents";
|
||||
// situation-composed delivery verb. Both are delivery-FREE library verbs
|
||||
// (get/profile SERVE bytes to the caller's stdout; nothing sends to a seat),
|
||||
// so the delivery-seam exclusion below still holds.
|
||||
const LIBRARY_SUBCOMMANDS = ["work-install", "trace", "compose", "list", "show", "preview", "get", "profile", "recap-write", "sync", "add", "source", "rm"];
|
||||
// Reconstruction inspect/view reads local immutable records; it neither
|
||||
// delivers to a seat nor starts or contacts a daemon.
|
||||
const LIBRARY_SUBCOMMANDS = ["reconstruction", "work-install", "trace", "compose", "list", "show", "preview", "get", "profile", "recap-write", "sync", "add", "source", "rm"];
|
||||
|
||||
function topLevelNames(program: Command): string[] {
|
||||
return program.commands.map((c) => c.name());
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { contextCommand } from "../src/commands/context.js";
|
||||
import { resolveWorkContextHome } from "../src/lib/work-install.js";
|
||||
|
||||
const fixture = JSON.parse(readFileSync(new URL("./fixtures/reconstruction-v0.json", import.meta.url), "utf8")) as {
|
||||
base: { id: string }; sourceStore: string;
|
||||
};
|
||||
|
||||
describe("rig context reconstruction", () => {
|
||||
let root: string, catalog: string, alpha: string, beta: string, home: string;
|
||||
beforeEach(() => {
|
||||
root = realpathSync(mkdtempSync(join(tmpdir(), "context-reconstruction-")));
|
||||
catalog = join(root, "catalog"); alpha = join(root, "alpha"); beta = join(root, "beta"); home = join(root, "instance");
|
||||
for (const path of [catalog, alpha, beta, home]) mkdirSync(path);
|
||||
for (const [id, path] of [["alpha", alpha], ["beta", beta]]) {
|
||||
const mission = join(path!, "workplans", "same-task");
|
||||
mkdirSync(join(mission, "slices", "01-example", "reconstructions"), { recursive: true });
|
||||
writeFileSync(join(path!, "project.yaml"), `schema: openrig.project/v0alpha1\nkind: project\nid: ${id}\nmissions: {root: workplans}\n`);
|
||||
writeFileSync(join(mission, "slices", "01-example", "SPEC.md"), "---\nid: OPR.99.0.1\n---\nExample slice\n");
|
||||
writeFileSync(join(mission, "slices", "01-example", "reconstructions", `${fixture.base.id}.json`), JSON.stringify(fixture.base));
|
||||
}
|
||||
writeFileSync(join(catalog, "workspace.yaml"), `schema: openrig.workspace/v0alpha1\nprojects:\n - id: alpha\n root: ${relative(catalog, alpha)}\n rigs: [example-rig]\n - id: beta\n root: ${relative(catalog, beta)}\n rigs: [other-rig]\n`);
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
vi.stubEnv("OPENRIG_WORKSPACE_ROOT", catalog);
|
||||
vi.stubEnv("OPENRIG_WORKSPACE_CATALOG_PATH", join(catalog, "workspace.yaml"));
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", ""); vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||||
// If this local command accidentally contacts the daemon, fail in the test.
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(() => { throw new Error("local reconstruction must not fetch"); });
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
async function run(args: string[]) {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const saved = process.exitCode; process.exitCode = undefined;
|
||||
try {
|
||||
const command = new Command().exitOverride(); command.addCommand(contextCommand());
|
||||
await command.parseAsync(["node", "rig", "context", "reconstruction", ...args]);
|
||||
return { output: log.mock.calls.map(call => call.join(" ")).join("\n"), errors: error.mock.calls, exitCode: process.exitCode };
|
||||
} finally { process.exitCode = saved; log.mockRestore(); error.mockRestore(); }
|
||||
}
|
||||
|
||||
it("resolves a catalog project, custom missions root and slice ID without writing or installing context", async () => {
|
||||
const result = await run(["inspect", fixture.base.id, "--project", "beta", "--mission", "same-task", "--slice", "OPR.99.0.1"]);
|
||||
const data = JSON.parse(result.output);
|
||||
expect(result.exitCode).toBeUndefined();
|
||||
expect(data.complete).toBe(true);
|
||||
expect(data.resolution.projectId).toBe("beta");
|
||||
expect(data.resolution.home).toBe(join(beta, "workplans", "same-task", "slices", "01-example"));
|
||||
expect(data.requestedStore).toBe(join(data.resolution.home, "reconstructions"));
|
||||
expect(data.nodes[0].sourceManifest).toHaveLength(2);
|
||||
expect(data.nodes[0].items).toBeUndefined();
|
||||
expect(readdirSync(home)).toEqual([]);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses shared cwd/rig inference rather than choosing another project's same mission", () => {
|
||||
const common = { workspaceRoot: catalog, catalogPath: join(catalog, "workspace.yaml"), mission: "same-task" };
|
||||
expect(resolveWorkContextHome({ ...common, cwd: join(beta, "workplans") })).toMatchObject({ projectId: "beta", selectedBy: "cwd" });
|
||||
expect(resolveWorkContextHome({ ...common, sessionName: "agent@example-rig", cwd: beta })).toMatchObject({ projectId: "alpha", selectedBy: "rig" });
|
||||
expect(resolveWorkContextHome({ ...common, cwd: root })).toMatchObject({ error: { code: "project_required" } });
|
||||
});
|
||||
|
||||
it("reads an explicit imported store even when the workspace configuration is unavailable", async () => {
|
||||
vi.stubEnv("OPENRIG_WORKSPACE_ROOT", join(root, "missing"));
|
||||
const store = join(alpha, "workplans", "same-task", "slices", "01-example", "reconstructions");
|
||||
const result = await run(["view", fixture.base.id, "--store", store, "--item", "decision", "--json"]);
|
||||
const data = JSON.parse(result.output);
|
||||
expect(data.complete).toBe(true);
|
||||
expect(data.resolution.mode).toBe("explicit-store");
|
||||
expect(data.nodes[0].items.map((v: { id: string }) => v.id)).toEqual(["decision", "dependency", "constraint", "gap", "authority"]);
|
||||
expect(data.nodes[0].viewCoverage.omittedIds).toEqual(["remove", "tagged", "omitted"]);
|
||||
expect(readdirSync(home)).toEqual([]);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns structured failures and nonzero status for missing context, invalid bounds or ambiguous projects", async () => {
|
||||
const ambiguous = await run(["inspect", fixture.base.id, "--cwd", root]);
|
||||
expect(ambiguous.exitCode).toBe(1); expect(JSON.parse(ambiguous.output).error.code).toBe("project_required");
|
||||
const unavailable = await run(["inspect", "b".repeat(64), "--store", root]);
|
||||
expect(unavailable.exitCode).toBe(1); expect(JSON.parse(unavailable.output).complete).toBe(false);
|
||||
const bound = await run(["view", fixture.base.id, "--store", root, "--max-bytes", "NaN", "--json"]);
|
||||
expect(bound.exitCode).toBe(1); expect(JSON.parse(bound.output).error.message).toContain("max-bytes");
|
||||
});
|
||||
|
||||
it("rejects paths escaping the selected mission and invalid mission composition", () => {
|
||||
const opts = { workspaceRoot: catalog, project: "alpha", mission: "same-task" };
|
||||
expect(resolveWorkContextHome({ ...opts, mission: "../beta" })).toMatchObject({ error: { code: "invalid_mission" } });
|
||||
expect(resolveWorkContextHome({ ...opts, slice: "../other" })).toMatchObject({ error: { code: "invalid_slice" } });
|
||||
symlinkSync(beta, join(alpha, "workplans", "same-task", "slices", "outside"));
|
||||
expect(resolveWorkContextHome({ ...opts, slice: "outside" })).toMatchObject({ error: { code: "slice_root_escape" } });
|
||||
writeFileSync(join(alpha, "workplans", "same-task", "mission.yaml"), "composition: {slices: not-a-list}\n");
|
||||
expect(resolveWorkContextHome({ ...opts, slice: "01-example" })).toMatchObject({ error: { code: "mission_composition_invalid" } });
|
||||
});
|
||||
});
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"provenance": "Sanitized fixtures using the v0 helper test shape; IDs computed with Python json.dumps(sort_keys=True,separators=(comma,colon),ensure_ascii=False). No real work records.",
|
||||
"sourceStore": "/example/original/reconstructions",
|
||||
"base": {
|
||||
"schema": "openrig.reconstruction/v0",
|
||||
"kind": "base",
|
||||
"subject": "work:example",
|
||||
"author": "example:author",
|
||||
"purpose": "Continue the selected example task",
|
||||
"depth": "working",
|
||||
"created_at": "2026-10-01T10:00:00+00:00",
|
||||
"coverage": {
|
||||
"included": [
|
||||
"selected task"
|
||||
],
|
||||
"excluded": [
|
||||
"credentials"
|
||||
]
|
||||
},
|
||||
"unknowns": [
|
||||
"No current runtime check"
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": "decision",
|
||||
"category": "decision",
|
||||
"text": "Working understanding decision",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00",
|
||||
"sources": [
|
||||
"route"
|
||||
],
|
||||
"depends_on": [
|
||||
"dependency"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dependency",
|
||||
"category": "evidence",
|
||||
"text": "Working understanding dependency",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "remove",
|
||||
"category": "decision",
|
||||
"text": "Working understanding remove",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "constraint",
|
||||
"category": "constraint",
|
||||
"text": "Working understanding constraint",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "gap",
|
||||
"category": "gap",
|
||||
"text": "Working understanding gap",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "authority",
|
||||
"category": "authority",
|
||||
"text": "Working understanding authority",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "tagged",
|
||||
"category": "detail",
|
||||
"text": "Working understanding tagged",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00",
|
||||
"tags": [
|
||||
"next"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "omitted",
|
||||
"category": "detail",
|
||||
"text": "Working understanding omitted",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00"
|
||||
}
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"id": "route",
|
||||
"address": "transcript:example:lines-1-5",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": "uncited",
|
||||
"address": "https://example.invalid/context",
|
||||
"observed_at": null
|
||||
}
|
||||
],
|
||||
"discovery": [],
|
||||
"id": "da27fa1f5276d29d1da0b3877cbc9344b25212b9e331a44e970963ae6a7af3f6"
|
||||
},
|
||||
"delta": {
|
||||
"schema": "openrig.reconstruction/v0",
|
||||
"kind": "delta",
|
||||
"subject": "work:example",
|
||||
"author": "example:author",
|
||||
"purpose": "Continue the selected example task",
|
||||
"depth": "working",
|
||||
"created_at": "2026-10-01T10:00:00+00:00",
|
||||
"parent": {
|
||||
"store": "/example/original/reconstructions",
|
||||
"revision": "da27fa1f5276d29d1da0b3877cbc9344b25212b9e331a44e970963ae6a7af3f6"
|
||||
},
|
||||
"upsert_items": [
|
||||
{
|
||||
"id": "decision",
|
||||
"category": "decision",
|
||||
"text": "A changed assessment based on new evidence",
|
||||
"observed_at": "2026-10-27T23:00:00+00:00",
|
||||
"sources": [
|
||||
"route"
|
||||
],
|
||||
"depends_on": [
|
||||
"dependency"
|
||||
]
|
||||
}
|
||||
],
|
||||
"remove_items": [
|
||||
"remove"
|
||||
],
|
||||
"id": "36623c590a22ce12401132ae90f9aae29fa88c07f6ae76ebf182fa5ed701a7a1"
|
||||
},
|
||||
"other": {
|
||||
"schema": "openrig.reconstruction/v0",
|
||||
"kind": "base",
|
||||
"subject": "seat:helper",
|
||||
"author": "example:author",
|
||||
"purpose": "Continue the selected example task",
|
||||
"depth": "working",
|
||||
"created_at": "2026-10-01T10:00:00+00:00",
|
||||
"items": [
|
||||
{
|
||||
"id": "activity",
|
||||
"category": "constraint",
|
||||
"text": "Working understanding activity",
|
||||
"observed_at": "2026-10-01T10:00:00+00:00"
|
||||
}
|
||||
],
|
||||
"sources": [],
|
||||
"created_note": "Different observation dates are not an atomic capture",
|
||||
"id": "d3dbba4b6dffe6aec89c7fc71be5f5ee07e231241ae3167b3a7e3d9bf1bc0fe3"
|
||||
},
|
||||
"composition": {
|
||||
"schema": "openrig.reconstruction/v0",
|
||||
"kind": "composition",
|
||||
"subject": "group:example",
|
||||
"author": "example:author",
|
||||
"purpose": "Continue the selected example task",
|
||||
"depth": "working",
|
||||
"created_at": "2026-10-01T10:00:00+00:00",
|
||||
"members": [
|
||||
{
|
||||
"store": "/example/original/reconstructions",
|
||||
"revision": "36623c590a22ce12401132ae90f9aae29fa88c07f6ae76ebf182fa5ed701a7a1"
|
||||
},
|
||||
{
|
||||
"store": "/example/original/reconstructions",
|
||||
"revision": "d3dbba4b6dffe6aec89c7fc71be5f5ee07e231241ae3167b3a7e3d9bf1bc0fe3"
|
||||
}
|
||||
],
|
||||
"id": "abdca73469bd2e7c75443d2e7648757616f04c62e449d623a97a6ede49f28b28"
|
||||
},
|
||||
"canonicalVector": {
|
||||
"value": {
|
||||
"10": "ten",
|
||||
"2": "two",
|
||||
"é": "été",
|
||||
"😀": "astral",
|
||||
"": "bmp",
|
||||
"__proto__": {
|
||||
"safe": true
|
||||
},
|
||||
"control": "\b\n\t",
|
||||
"ints": [
|
||||
0,
|
||||
-4,
|
||||
9007199254740991
|
||||
],
|
||||
"values": [
|
||||
null,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"canonical": "{\"10\":\"ten\",\"2\":\"two\",\"__proto__\":{\"safe\":true},\"control\":\"\\b\\n\\t\",\"ints\":[0,-4,9007199254740991],\"values\":[null,false,true],\"é\":\"été\",\"\":\"bmp\",\"😀\":\"astral\"}",
|
||||
"sha256": "9c3b2f8426bd1ed10bf2740a1a759e6f199431f9f862af0bce968c49be627b7d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { canonicalV0, parseReconstructionJson, readReconstruction, readStoreMappings, reconstructionOutput } from "../src/lib/reconstruction-reader.js";
|
||||
|
||||
type Doc = Record<string, unknown> & { id: string };
|
||||
const fixture = JSON.parse(readFileSync(new URL("./fixtures/reconstruction-v0.json", import.meta.url), "utf8")) as {
|
||||
sourceStore: string; base: Doc; delta: Doc; other: Doc; composition: Doc;
|
||||
canonicalVector: { value: unknown; canonical: string; sha256: string };
|
||||
};
|
||||
const sha = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
||||
// Dynamic malformed/limit examples use native hashing; compatibility assertions
|
||||
// below use frozen Python-computed IDs, not an expected value from this reader.
|
||||
function seal(value: Record<string, unknown>): Doc {
|
||||
const { id: ignored, ...body } = value; void ignored;
|
||||
return { ...body, id: sha(canonicalV0(parseReconstructionJson(JSON.stringify(body)))) };
|
||||
}
|
||||
|
||||
describe("local v0 reconstruction reader", () => {
|
||||
let root: string, store: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "reconstruction-reader-")); store = join(root, "import"); mkdirSync(store);
|
||||
for (const doc of [fixture.base, fixture.delta, fixture.other, fixture.composition]) save(doc);
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
function save(doc: Doc) { writeFileSync(join(store, `${doc.id}.json`), JSON.stringify(doc)); return doc.id; }
|
||||
const mappings = () => [{ sourceStore: fixture.sourceStore, localStore: store, sourceHost: "example-source" }];
|
||||
const read = (id = fixture.composition.id, extra: Partial<Parameters<typeof readReconstruction>[0]> = {}) =>
|
||||
readReconstruction({ store, revision: id, mappings: mappings(), ...extra });
|
||||
|
||||
it("reproduces Python's canonical Unicode/key/integer preimage and four frozen record IDs", () => {
|
||||
const vector = fixture.canonicalVector;
|
||||
expect(canonicalV0(parseReconstructionJson(JSON.stringify(vector.value)))).toBe(vector.canonical);
|
||||
expect(sha(vector.canonical)).toBe(vector.sha256);
|
||||
for (const doc of [fixture.base, fixture.delta, fixture.other, fixture.composition]) {
|
||||
const { id, ...body } = doc;
|
||||
expect(sha(canonicalV0(parseReconstructionJson(JSON.stringify(body))))).toBe(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a relocated base/delta/composition without rewriting IDs, bytes or observation dates", () => {
|
||||
const before = readFileSync(join(store, `${fixture.base.id}.json`));
|
||||
const result = read();
|
||||
expect(result.complete).toBe(true);
|
||||
expect(result.nodes.map(n => n.subject)).toEqual(["group:example", "work:example", "seat:helper"]);
|
||||
const work = result.nodes[1]!;
|
||||
expect(work.lineage.map(v => v.revision)).toEqual([fixture.base.id, fixture.delta.id]);
|
||||
expect(work.lineage[0]!.store).toBe(fixture.sourceStore);
|
||||
expect(work.items.find(i => i.id === "decision")!.observed_at).toBe("2026-10-27T23:00:00+00:00");
|
||||
expect(work.items.find(i => i.id === "dependency")!.observed_at).toBe("2026-10-01T10:00:00+00:00");
|
||||
expect(work.items.some(i => i.id === "remove")).toBe(false);
|
||||
expect(result.atomicGroupCapture).toBe(false);
|
||||
expect(readFileSync(join(store, `${fixture.base.id}.json`))).toEqual(before);
|
||||
expect(result.caveat).toContain("not authority");
|
||||
});
|
||||
|
||||
it("uses selector union, includes dependencies/constraints/gaps/authority and names omissions", () => {
|
||||
const result = read(undefined, { items: ["decision"], tags: ["next"] });
|
||||
const work = result.nodes[1]!;
|
||||
expect(work.items.map(i => i.id)).toEqual(["decision", "dependency", "constraint", "gap", "authority", "tagged"]);
|
||||
expect(work.viewCoverage.omittedIds).toEqual(["omitted"]);
|
||||
expect(work.viewCoverage.omittedRouteIds).toEqual(["uncited"]);
|
||||
expect(work.sourceManifest.map(s => s.id)).toEqual(["route", "uncited"]);
|
||||
expect(work.viewSources.map(s => s.id)).toEqual(["route"]);
|
||||
expect(result.nodes[2]!.items.map(i => i.id)).toEqual(["activity"]);
|
||||
const inspect = JSON.parse(reconstructionOutput(result, "inspect", true));
|
||||
expect(inspect.nodes[1].items).toBeUndefined();
|
||||
expect(inspect.nodes[1].sourceManifest).toHaveLength(2);
|
||||
expect(JSON.parse(reconstructionOutput(result, "view", true)).nodes[1].sourceManifest).toBeUndefined();
|
||||
expect(reconstructionOutput(read(), "view", false)).toContain("https://example.invalid/context");
|
||||
expect(reconstructionOutput(result, "view", false)).toContain("inspect this exact revision");
|
||||
});
|
||||
|
||||
it("does not infer missing selections, citations, dependencies or a newer revision", () => {
|
||||
expect(read(undefined, { items: ["absent"] }).issues.map(i => i.code)).toContain("missing_item");
|
||||
const doc = seal({ ...fixture.base, items: [{ id: "x", category: "decision", text: "Needs evidence", observed_at: null, sources: ["missing"], depends_on: ["missing"] }] });
|
||||
const result = read(save(doc));
|
||||
expect(result.complete).toBe(false);
|
||||
expect(result.issues.map(i => i.code)).toEqual(["missing_dependency", "missing_citation"]);
|
||||
expect(result.nodes[0]!.itemObservations[0]!.observedAt).toBeNull();
|
||||
expect(() => readReconstruction({ store, revision: "latest" })).toThrow(/full lowercase SHA256/);
|
||||
});
|
||||
|
||||
it("retains available composition members but reports an unmapped/missing member as incomplete", () => {
|
||||
const unmapped = read(undefined, { mappings: [] });
|
||||
expect(unmapped.complete).toBe(false);
|
||||
expect(unmapped.issues.every(i => i.code === "unmapped_external_store")).toBe(true);
|
||||
rmSync(join(store, `${fixture.base.id}.json`));
|
||||
const missing = read();
|
||||
expect(missing.complete).toBe(false);
|
||||
expect(missing.nodes.map(n => n.subject)).toEqual(["group:example", "seat:helper"]);
|
||||
expect(missing.issues[0]!.revision).toBe(fixture.base.id);
|
||||
});
|
||||
|
||||
it("reads a same-host cross-store composition without a relocation mapping", () => {
|
||||
const otherStore = join(root, "slice-store"); mkdirSync(otherStore);
|
||||
writeFileSync(join(otherStore, `${fixture.other.id}.json`), JSON.stringify(fixture.other));
|
||||
const doc = seal({ ...fixture.composition, members: [{ store: otherStore, revision: fixture.other.id }] });
|
||||
const result = read(save(doc), { mappings: [] });
|
||||
expect(result.complete).toBe(true);
|
||||
expect(result.nodes.map(node => node.subject)).toEqual(["group:example", "seat:helper"]);
|
||||
expect(result.records[1]).toMatchObject({ sourceStore: otherStore, localStore: otherStore, revision: fixture.other.id });
|
||||
});
|
||||
|
||||
it.each(["{", "1e2"])("charges malformed member reads before parsing %s and stops at the closure budget", input => {
|
||||
const budget = 32 * 1024 * 1024, perFile = 8 * 1024 * 1024;
|
||||
const ids = Array.from({ length: 6 }, (_, n) => sha(`invalid-member-${n}`));
|
||||
const doc = seal({ ...fixture.composition, members: ids.map(revision => ({ store, revision })) });
|
||||
const rootBytes = Buffer.byteLength(JSON.stringify(doc)); save(doc);
|
||||
// Three near-limit members, then exactly the remaining budget; later members
|
||||
// must not be opened. Neither corrupt JSON nor unsupported numbers are free IO.
|
||||
const nearLimit = perFile - 1;
|
||||
for (let n = 0; n < ids.length; n++) {
|
||||
const size = n === 3 ? budget - rootBytes - 3 * nearLimit : nearLimit;
|
||||
expect(size).toBeLessThanOrEqual(perFile);
|
||||
const bytes = Buffer.alloc(size, 32); bytes.write(input);
|
||||
writeFileSync(join(store, `${ids[n]}.json`), bytes);
|
||||
}
|
||||
const result = read(doc.id);
|
||||
expect(result.complete).toBe(false);
|
||||
expect(result.closureBytesRead).toBe(budget);
|
||||
expect(result.records.map(record => record.revision)).toEqual([doc.id]);
|
||||
expect(result.issues.slice(0, 4).map(issue => issue.revision)).toEqual(ids.slice(0, 4));
|
||||
expect(result.issues[4]).toMatchObject({ code: "closure_limit", revision: ids[4] });
|
||||
expect(result.issues).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("requires unambiguous explicit local mapping and preserves its provenance", () => {
|
||||
const file = join(root, "map.json"); writeFileSync(file, JSON.stringify(mappings()));
|
||||
expect(readStoreMappings(file)).toEqual(mappings());
|
||||
expect(read(undefined, { mappings: readStoreMappings(file) }).mappings[0]!.sourceHost).toBe("example-source");
|
||||
expect(() => read(undefined, { mappings: [...mappings(), { ...mappings()[0]!, sourceHost: "other-host" }] })).toThrow(/ambiguous/);
|
||||
writeFileSync(file, '[{"sourceStore":"relative","localStore":"/tmp"}]');
|
||||
expect(() => readStoreMappings(file)).toThrow(/absolute/);
|
||||
});
|
||||
|
||||
it("rejects tampering, unsupported schemas, duplicate subjects and semantic invalidity", () => {
|
||||
writeFileSync(join(store, `${fixture.base.id}.json`), JSON.stringify({ ...fixture.base, purpose: "Changed bytes" }));
|
||||
expect(read(fixture.base.id).issues[0]!.message).toContain("hash mismatch");
|
||||
expect(read(save(seal({ ...fixture.base, schema: "openrig.reconstruction/v999" }))).issues[0]!.code).toBe("unsupported_schema");
|
||||
const duplicate = seal({ ...fixture.composition, members: [{ store, revision: fixture.other.id }, { store, revision: fixture.other.id }] });
|
||||
expect(read(save(duplicate)).issues[0]!.code).toBe("duplicate_subject");
|
||||
for (const invalid of [
|
||||
{ ...fixture.delta, remove_items: ["not-present"] },
|
||||
{ ...fixture.delta, subject: "different" },
|
||||
{ ...fixture.delta, items: [] },
|
||||
{ ...fixture.other, remove_items: [] },
|
||||
{ ...fixture.other, discovery: [{ id: "d", path: "/tmp", observed_at: null, known_paths: ["../outside"] }] },
|
||||
]) expect(read(save(seal(invalid))).complete).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['1.0', '1e2', '9007199254740992', '{"a":1,"a":2}', '"\\ud800"', '[1,]', '{"a":1}garbage'])
|
||||
("explicitly rejects incompatible/invalid JSON %s", input => expect(() => parseReconstructionJson(input)).toThrow());
|
||||
|
||||
it("bounds closure reads, individual files, nesting and output without silently truncating", () => {
|
||||
let id = fixture.other.id;
|
||||
for (let n = 0; n < 65; n++) id = save(seal({ ...fixture.other, kind: "delta", items: undefined, sources: undefined, parent: { store, revision: id } }));
|
||||
const result = read(id);
|
||||
expect(result.complete).toBe(false);
|
||||
expect(result.records.length).toBeLessThanOrEqual(64);
|
||||
expect(result.issues.some(i => i.code === "closure_limit")).toBe(true);
|
||||
const fake = "a".repeat(64); writeFileSync(join(store, `${fake}.json`), Buffer.alloc(8 * 1024 * 1024 + 1, 32));
|
||||
expect(read(fake).issues[0]!.message).toContain("byte bound");
|
||||
expect(() => parseReconstructionJson("[".repeat(130) + "0" + "]".repeat(130))).toThrow(/nesting/);
|
||||
expect(() => reconstructionOutput(read(), "view", true, 1024)).toThrow(/no complete output/);
|
||||
});
|
||||
|
||||
it("checks local source bytes without refreshing facts, fetching opaque routes or following symlinks", () => {
|
||||
const source = join(root, "evidence.txt"); writeFileSync(source, "before");
|
||||
const link = join(root, "link"); symlinkSync(source, link);
|
||||
const doc = seal({ ...fixture.other, sources: [
|
||||
{ id: "local", path: source, sha256: sha("before"), observed_at: "2026-10-01T10:00:00Z" },
|
||||
{ id: "link", path: link, observed_at: null },
|
||||
{ id: "opaque", address: "transcript:example", observed_at: null },
|
||||
] });
|
||||
save(doc);
|
||||
expect(read(doc.id).sourceChecks).toBeUndefined();
|
||||
expect(read(doc.id, { checkSources: true }).sourceChecks![0]!.status).toBe("unchanged");
|
||||
writeFileSync(source, "after");
|
||||
const checked = read(doc.id, { checkSources: true });
|
||||
expect(checked.sourceChecks!.map(s => s.status)).toEqual(["changed", "unavailable", "unchecked"]);
|
||||
expect(checked.sourceChecks![0]!.observedAt).toBe("2026-10-01T10:00:00Z");
|
||||
expect(checked.nodes[0]!.sourceManifest[0]!.sha256).toBe(sha("before"));
|
||||
const destination = join(store, `${doc.id}.json`); rmSync(destination); symlinkSync(source, destination);
|
||||
expect(read(doc.id).complete).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user