Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions packages/reactor-devtools/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packages/reactor-devtools/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ function handle(
return;
}

// S4 click-through: GET /api/node/:id?version=<atomicVersion>.
// S4 click-through: GET /api/node/:id?version=<version|atomicVersion>.
if (path.startsWith("/api/node/")) {
const node = decodeURIComponent(path.slice("/api/node/".length));
if (node.length === 0) {
Expand All @@ -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;
}
Expand Down
80 changes: 79 additions & 1 deletion packages/reactor-devtools/src/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
}
});
123 changes: 123 additions & 0 deletions packages/reactor/src/world-model/__tests__/fs-store-alias.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
36 changes: 34 additions & 2 deletions packages/reactor/src/world-model/fs-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { join } from "node:path";

import {
asNodeId,
ATOMIC_FACET,
type ContentAddress,
type FingerprintMap,
type WorldModelCommit,
Expand Down Expand Up @@ -189,14 +190,41 @@ 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 };
}

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;
}
Expand All @@ -205,7 +233,7 @@ export class FileSystemWorldModelStore implements WorldModelStore {
node: asNodeId(node),
workspace: "published",
location: this.#publishedLocation(node),
version,
version: targetVersion,
},
files,
};
Expand Down Expand Up @@ -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
Expand Down
Loading