From 32aa3392e574901078cacb2ee9d146181c632bfd Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:54:52 +0530 Subject: [PATCH] fix(devtools,world-model): resolve atomic fingerprint to artifact version in store and inspector --- packages/reactor-devtools/src/data/index.ts | 31 ++++- packages/reactor-devtools/src/server/index.ts | 4 +- .../src/server/server.test.ts | 80 +++++++++++- .../__tests__/fs-store-alias.test.ts | 123 ++++++++++++++++++ packages/reactor/src/world-model/fs-store.ts | 36 ++++- packages/reactor/src/world-model/store.ts | 36 ++++- 6 files changed, 293 insertions(+), 17 deletions(-) create mode 100644 packages/reactor/src/world-model/__tests__/fs-store-alias.test.ts diff --git a/packages/reactor-devtools/src/data/index.ts b/packages/reactor-devtools/src/data/index.ts index d50d615..45c38c6 100644 --- a/packages/reactor-devtools/src/data/index.ts +++ b/packages/reactor-devtools/src/data/index.ts @@ -376,7 +376,13 @@ export function openWorldModels( stateDir: string, ): WorldModelStore | null { const directory = join(stateDir, "world-models"); - if (!existsSync(directory)) return null; + if (!existsSync(directory)) { + const fallback = join(stateDir, "world-model"); + if (existsSync(fallback)) { + return createFileSystemWorldModelStore({ directory: fallback }); + } + return null; + } return createFileSystemWorldModelStore({ directory }); } @@ -415,10 +421,10 @@ function decodeText(bytes: Uint8Array): string | null { } /** - * Read a node's world-model at a content-addressed version via the store's - * `readVersion` (R3 resolved: pass `receipt.fingerprints["@atomic"]`). Returns - * `null` when there is no store, no such node, or no such version. PURE read of - * the saved `world-models/` dir — no key, no running reactor. + * Read a node's world-model at a version (accepting either raw store artifact + * version address or receipt frame `atomicVersion`). Returns `null` when there + * is no store, no such node, or no such version. PURE read of the saved + * `world-models/` dir — no key, no running reactor. */ export function readNodeWorldModel( opened: OpenedStateDir, @@ -429,13 +435,24 @@ export function readNodeWorldModel( if (store === null) return null; let read; try { - // The URL `version` is a content address by contract (R3: a frame's - // `atomicVersion` = `fingerprints["@atomic"]`). Cast at this boundary. + // The URL `version` can be a raw store artifact address or a frame's + // `atomicVersion` (= `fingerprints["@atomic"]`). read = store.readVersion(node, version as ContentAddress); } catch { // `readVersion` asserts the node name; an unknown node is "not found". return null; } + if (read === null) { + // Defensive fallback: check if current published pointer matches requested atomic version + try { + const fps = store.publishedFingerprints(node); + if (fps[ATOMIC_FACET] === version) { + read = store.read(node, "published"); + } + } catch { + // ignore + } + } if (read === null) return null; const files: WorldModelFileView[] = Object.entries(read.files).map( diff --git a/packages/reactor-devtools/src/server/index.ts b/packages/reactor-devtools/src/server/index.ts index 682f6ec..d34a1da 100644 --- a/packages/reactor-devtools/src/server/index.ts +++ b/packages/reactor-devtools/src/server/index.ts @@ -155,7 +155,7 @@ function handle( return; } - // S4 click-through: GET /api/node/:id?version=. + // S4 click-through: GET /api/node/:id?version=. if (path.startsWith("/api/node/")) { const node = decodeURIComponent(path.slice("/api/node/".length)); if (node.length === 0) { @@ -166,7 +166,7 @@ function handle( const version = new URLSearchParams(qs).get("version"); if (version === null || version.length === 0) { sendJson(res, 400, { - error: "missing ?version= (a frame's atomicVersion)", + error: "missing ?version= (a frame's atomicVersion or store artifact version)", }); return; } diff --git a/packages/reactor-devtools/src/server/server.test.ts b/packages/reactor-devtools/src/server/server.test.ts index e149712..fd8eae7 100644 --- a/packages/reactor-devtools/src/server/server.test.ts +++ b/packages/reactor-devtools/src/server/server.test.ts @@ -16,7 +16,16 @@ import { strict as assert } from "node:assert"; import { test, before, after } from "node:test"; import { join } from "node:path"; -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { + createFileSystemWorldModelStore, + files, + jsonFile, + ATOMIC_FACET, + type Fingerprint, +} from "@openprose/reactor"; import { startDevToolsServer, @@ -227,3 +236,72 @@ test("path traversal is rejected (no escaping the assets dir)", async () => { assert.ok(!body.includes("@openprose/reactor-devtools"), "did not leak package.json"); } }); + +test("GET /api/node/:id?version resolves when version is an atomic fingerprint alias (distinct from store version)", async () => { + const tmp = mkdtempSync(join(tmpdir(), "devtools-alias-test-")); + try { + const wmDir = join(tmp, "world-models"); + const store = createFileSystemWorldModelStore({ directory: wmDir }); + + const atomicFp = "sha256:2222222222222222222222222222222222222222222222222222222222222222" as Fingerprint; + + // Commit under artifact content version with a custom atomic fingerprint that differs + const commit = store.commitPublished( + "worker-node", + files({ "output.json": jsonFile({ status: "success" }) }), + () => ({ + [ATOMIC_FACET]: atomicFp, + }), + ); + + assert.notEqual(commit.version, atomicFp, "atomic fingerprint must differ from artifact version"); + + // Write minimal receipts.json referencing this atomic version in receipts + const receipts = [ + { + schema: "openprose.receipt", + node: "worker-node", + status: "rendered", + hash_algorithm: "sha256", + contract_fingerprint: "contract:worker-node@v1", + fingerprints: { + [ATOMIC_FACET]: atomicFp, + }, + input_fingerprints: [], + cost: { + model: "none", + provider: "none", + surprise_cause: "external", + tokens: { fresh: 0, reused: 0 }, + }, + wake: { refs: [], source: "external" }, + prev: null, + semantic_diff: {}, + sig: { scheme: "none", null_reason: "no-signer-adapter-configured" }, + }, + ]; + writeFileSync(join(tmp, "receipts.json"), JSON.stringify(receipts, null, 2), "utf-8"); + + const server = await startDevToolsServer({ stateDir: tmp, port: 0 }); + try { + // Fetch by atomicVersion query param (as DevTools S4 inspector does) + const url = new URL("/api/node/worker-node", server.url); + url.searchParams.set("version", atomicFp); + + const res = await fetch(url); + assert.equal(res.status, 200, "alias query must resolve with 200"); + const data = (await res.json()) as { + node: string; + version: string; + files: { path: string; text: string | null }[]; + }; + assert.equal(data.node, "worker-node"); + assert.equal(data.files.length, 1); + assert.equal(data.files[0]!.path, "output.json"); + } finally { + await server.close(); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/packages/reactor/src/world-model/__tests__/fs-store-alias.test.ts b/packages/reactor/src/world-model/__tests__/fs-store-alias.test.ts new file mode 100644 index 0000000..2b4a54b --- /dev/null +++ b/packages/reactor/src/world-model/__tests__/fs-store-alias.test.ts @@ -0,0 +1,123 @@ +import { deepEqual, equal, notEqual, ok } from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { + asFingerprint, + asNodeId, + ATOMIC_FACET, + type ContentAddress, + type FingerprintMap, +} from "../../shapes"; +import { + createFileSystemWorldModelStore, + createInMemoryWorldModelStore, + jsonFile, + readTextFile, + type WorldModelFiles, +} from "../index"; +import type { FileSystemWorldModelStore } from "../fs-store"; + +test("fs-store: readVersion resolves both raw artifact version and @atomic fingerprint alias", () => { + const root = mkdtempSync(join(tmpdir(), "fs-store-alias-test-")); + try { + const store = createFileSystemWorldModelStore({ directory: root }); + const node = "monitor"; + + // Custom canonicalizer where @atomic is computed over a structured projection + // and does NOT equal contentAddressOf(bytes) + const structuredCanonicalizer = (files: WorldModelFiles): FingerprintMap => { + const data = JSON.parse(readTextFile(files["data.json"]!)); + return { + [ATOMIC_FACET]: asFingerprint(`sha256:structured-${data.v}`), + }; + }; + + const files1: WorldModelFiles = { + "data.json": jsonFile({ v: 1, extra: "noise" }), + }; + + const commit1 = store.commitPublished(node, files1, structuredCanonicalizer); + const version1 = commit1.version; + const atomic1 = commit1.fingerprints[ATOMIC_FACET] as ContentAddress; + + notEqual(version1, atomic1, "version and atomic fingerprint must differ in structured canonicalizer"); + + // 1. readVersion by raw artifact version + const readByVer = store.readVersion(node, version1); + ok(readByVer, "must read by raw artifact version"); + deepEqual(JSON.parse(readTextFile(readByVer.files["data.json"]!)), { v: 1, extra: "noise" }); + + // 2. readVersion by atomic fingerprint + const readByAtomic = store.readVersion(node, atomic1); + ok(readByAtomic, "must read by atomic fingerprint"); + deepEqual(JSON.parse(readTextFile(readByAtomic.files["data.json"]!)), { v: 1, extra: "noise" }); + + // Commit a second version to verify history + const files2: WorldModelFiles = { + "data.json": jsonFile({ v: 2, extra: "noise-2" }), + }; + const commit2 = store.commitPublished(node, files2, structuredCanonicalizer); + const version2 = commit2.version; + const atomic2 = commit2.fingerprints[ATOMIC_FACET] as ContentAddress; + + notEqual(version2, atomic2); + notEqual(version1, version2); + + // Both historical versions must be readable by their respective atomic fingerprints + const hist1 = store.readVersion(node, atomic1); + const hist2 = store.readVersion(node, atomic2); + ok(hist1, "historical version 1 must resolve by atomic fingerprint"); + ok(hist2, "historical version 2 must resolve by atomic fingerprint"); + + deepEqual(JSON.parse(readTextFile(hist1.files["data.json"]!)), { v: 1, extra: "noise" }); + deepEqual(JSON.parse(readTextFile(hist2.files["data.json"]!)), { v: 2, extra: "noise-2" }); + + // retainedVersions should only list the .bin artifact versions, not .alias files + const retained = (store as FileSystemWorldModelStore).retainedVersions(node); + equal(retained.length, 2, "retainedVersions must count only artifact files"); + ok(retained.includes(version1)); + ok(retained.includes(version2)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("in-memory-store: readVersion resolves both raw artifact version and @atomic fingerprint alias", () => { + const store = createInMemoryWorldModelStore(); + const node = "analyzer"; + + const structuredCanonicalizer = (files: WorldModelFiles): FingerprintMap => { + const data = JSON.parse(readTextFile(files["data.json"]!)); + return { + [ATOMIC_FACET]: asFingerprint(`sha256:structured-${data.v}`), + }; + }; + + const files1: WorldModelFiles = { + "data.json": jsonFile({ v: 1, extra: "a" }), + }; + const commit1 = store.commitPublished(node, files1, structuredCanonicalizer); + const version1 = commit1.version; + const atomic1 = commit1.fingerprints[ATOMIC_FACET] as ContentAddress; + + notEqual(version1, atomic1); + + const read1 = store.readVersion(node, version1); + const readAtomic1 = store.readVersion(node, atomic1); + ok(read1); + ok(readAtomic1); + deepEqual(JSON.parse(readTextFile(readAtomic1.files["data.json"]!)), { v: 1, extra: "a" }); + + const files2: WorldModelFiles = { + "data.json": jsonFile({ v: 2, extra: "b" }), + }; + const commit2 = store.commitPublished(node, files2, structuredCanonicalizer); + const atomic2 = commit2.fingerprints[ATOMIC_FACET] as ContentAddress; + + const readAtomic2 = store.readVersion(node, atomic2); + ok(readAtomic2); + deepEqual(JSON.parse(readTextFile(readAtomic2.files["data.json"]!)), { v: 2, extra: "b" }); +}); diff --git a/packages/reactor/src/world-model/fs-store.ts b/packages/reactor/src/world-model/fs-store.ts index cb9f55f..e70491d 100644 --- a/packages/reactor/src/world-model/fs-store.ts +++ b/packages/reactor/src/world-model/fs-store.ts @@ -59,6 +59,7 @@ import { join } from "node:path"; import { asNodeId, + ATOMIC_FACET, type ContentAddress, type FingerprintMap, type WorldModelCommit, @@ -189,6 +190,13 @@ export class FileSystemWorldModelStore implements WorldModelStore { if (!existsSync(versionFile)) { atomicWrite(versionFile, bytes); } + const atomicFp = fingerprints[ATOMIC_FACET]; + if (atomicFp !== undefined && atomicFp !== version) { + const aliasFile = this.#aliasFile(node, atomicFp as ContentAddress); + if (!existsSync(aliasFile)) { + atomicWriteText(aliasFile, version); + } + } this.#writePublishedPointer(node, { version, fingerprints }); return { node: asNodeId(node), version, fingerprints }; @@ -196,7 +204,27 @@ export class FileSystemWorldModelStore implements WorldModelStore { readVersion(node: string, version: ContentAddress): WorldModelRead | null { assertNode(node); - const files = this.#readVersionFiles(node, version); + let targetVersion = version; + let files = this.#readVersionFiles(node, targetVersion); + if (!files) { + const aliasFile = this.#aliasFile(node, version); + if (existsSync(aliasFile)) { + const resolved = readFileSync(aliasFile, "utf8").trim() as ContentAddress; + files = this.#readVersionFiles(node, resolved); + if (files) { + targetVersion = resolved; + } + } + } + if (!files) { + const pointer = this.#readPublishedPointer(node); + if (pointer && pointer.fingerprints[ATOMIC_FACET] === version) { + files = this.#readVersionFiles(node, pointer.version); + if (files) { + targetVersion = pointer.version; + } + } + } if (!files) { return null; } @@ -205,7 +233,7 @@ export class FileSystemWorldModelStore implements WorldModelStore { node: asNodeId(node), workspace: "published", location: this.#publishedLocation(node), - version, + version: targetVersion, }, files, }; @@ -241,6 +269,10 @@ export class FileSystemWorldModelStore implements WorldModelStore { return join(this.#nodeDir(node), VERSIONS_DIR, `${addressSegment(version)}.bin`); } + #aliasFile(node: string, atomicFp: ContentAddress): string { + return join(this.#nodeDir(node), VERSIONS_DIR, `${addressSegment(atomicFp)}.alias`); + } + /** * The published `location` handed back on a ref. A stable, queryable path the * render reads BY REFERENCE (world-model.md §1 L24–L33) — the published face diff --git a/packages/reactor/src/world-model/store.ts b/packages/reactor/src/world-model/store.ts index b1717ca..19da666 100644 --- a/packages/reactor/src/world-model/store.ts +++ b/packages/reactor/src/world-model/store.ts @@ -121,9 +121,10 @@ export interface WorldModelStore { ): WorldModelCommit; /** - * Read a specific historical published version by its content address — the - * content-addressed read-isolation primitive a render pins at start - * (architecture.md §8 L328–L330). `null` if that version is not retained. + * Read a specific historical published version by its content address (or + * its atomic facet fingerprint) — the content-addressed read-isolation + * primitive a render pins at start (architecture.md §8 L328–L330). + * `null` if that version is not retained. */ readVersion(node: string, version: ContentAddress): WorldModelRead | null; @@ -178,6 +179,7 @@ export class InMemoryWorldModelStore implements WorldModelStore { readonly #published = new Map(); readonly #workspace = new Map(); readonly #history = new Map>(); + readonly #aliases = new Map>(); ref( node: string, @@ -249,13 +251,37 @@ export class InMemoryWorldModelStore implements WorldModelStore { this.#history.set(node, history); } history.set(version, frozen); + const atomicFp = fingerprints[ATOMIC_FACET]; + if (atomicFp !== undefined && (atomicFp as string) !== version) { + let aliases = this.#aliases.get(node); + if (!aliases) { + aliases = new Map(); + this.#aliases.set(node, aliases); + } + aliases.set(atomicFp as ContentAddress, version); + } return { node: asNodeId(node), version, fingerprints }; } readVersion(node: string, version: ContentAddress): WorldModelRead | null { assertNode(node); - const files = this.#history.get(node)?.get(version); + let targetVersion = version; + let files = this.#history.get(node)?.get(targetVersion); + if (!files) { + const resolved = this.#aliases.get(node)?.get(version); + if (resolved) { + targetVersion = resolved; + files = this.#history.get(node)?.get(targetVersion); + } + } + if (!files) { + const pub = this.#published.get(node); + if (pub && pub.fingerprints[ATOMIC_FACET] === version) { + targetVersion = pub.version; + files = pub.files; + } + } if (!files) { return null; } @@ -264,7 +290,7 @@ export class InMemoryWorldModelStore implements WorldModelStore { node: asNodeId(node), workspace: "published", location: publishedLocation(node), - version, + version: targetVersion, }, files, };