mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e333a91ff | ||
|
|
91789cd328 | ||
|
|
94c2a52e03 | ||
|
|
4f33a14380 | ||
|
|
0e8ccc505f |
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. All external store pointers require a mapping,
|
||||
even if that absolute path happens to exist on the current host.
|
||||
|
||||
## 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,385 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, openSync, closeSync, fstatSync, lstatSync, readSync } 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): 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 + 1 - bytes));
|
||||
const count = readSync(fd, buffer); if (!count) break;
|
||||
bytes += count; requireValue(bytes <= limit, `file exceeds byte bound: ${file}`); 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): { value: Json; bytes: number } {
|
||||
const bytes = readRegular(file, Math.min(MAX_DOCUMENT, remainingBytes));
|
||||
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) throw new ReconstructionError("unmapped_external_store", `external store ${sourceStore} requires an explicit store mapping; no implicit remote read`);
|
||||
const localStore = mapping?.localStore ?? sourceStore;
|
||||
const file = jsonFile(join(localStore, `${revision(id)}.json`), MAX_CLOSURE - totalBytes); totalBytes += file.bytes;
|
||||
if (totalBytes > MAX_CLOSURE) {
|
||||
exhausted = true;
|
||||
throw new ReconstructionError("closure_limit", "reconstruction closure exceeds 32 MiB");
|
||||
}
|
||||
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); sourceBytes += bytes.length;
|
||||
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