Merge pull request 'feat(#194): Phase 1 — Merkle Call Stack protocol + CAS layer' (#199) from feat/194-merkle-call-stack-phase1 into main
This commit is contained in:
@@ -45,8 +45,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -100,8 +100,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -135,8 +135,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ function payload(
|
||||
ancestors: partial.ancestors ?? [],
|
||||
compact: partial.compact ?? null,
|
||||
timestamp: partial.timestamp ?? 0,
|
||||
childThread: partial.childThread ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,4 +63,32 @@ describe("collectRefs", () => {
|
||||
);
|
||||
expect(refs).toEqual(["S2", "C2"]);
|
||||
});
|
||||
|
||||
test("includes childThread hash when childThread is non-null", () => {
|
||||
const refs = collectRefs(
|
||||
payload({
|
||||
role: "developer",
|
||||
start: "S3",
|
||||
content: "C3",
|
||||
ancestors: ["A3"],
|
||||
compact: null,
|
||||
childThread: "CHILDEND000000000000001",
|
||||
}),
|
||||
);
|
||||
expect(refs).toEqual(["S3", "C3", "A3", "CHILDEND000000000000001"]);
|
||||
});
|
||||
|
||||
test("does not include childThread when childThread is null", () => {
|
||||
const refs = collectRefs(
|
||||
payload({
|
||||
role: "developer",
|
||||
start: "S4",
|
||||
content: "C4",
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
childThread: null,
|
||||
}),
|
||||
);
|
||||
expect(refs).toEqual(["S4", "C4"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { parseCasThreadNode, putStartNode, putStateNode } from "../src/nodes.js";
|
||||
|
||||
describe("putStartNode — parentState in refs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "wf-cas-nodes-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("refs contains only promptHash when parentState is null", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const promptHash = await cas.put("hello");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: "BUNDLEAAAAAAAAA", depth: 0, parentState: null },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
const blob = await cas.get(startHash);
|
||||
expect(blob).not.toBeNull();
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.kind).toBe("start");
|
||||
if (parsed?.kind !== "start") return;
|
||||
|
||||
expect(parsed.node.refs).toEqual([promptHash]);
|
||||
expect(parsed.node.payload.parentState).toBeNull();
|
||||
});
|
||||
|
||||
test("refs contains [promptHash, parentStateHash] when parentState is set", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const parentStateHash = await cas.put("fake-parent-state");
|
||||
const promptHash = await cas.put("child-prompt");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "develop", hash: "BUNDLEBBBBBBBBB", depth: 1, parentState: parentStateHash },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
const blob = await cas.get(startHash);
|
||||
expect(blob).not.toBeNull();
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.kind).toBe("start");
|
||||
if (parsed?.kind !== "start") return;
|
||||
|
||||
expect(parsed.node.refs).toEqual([promptHash, parentStateHash]);
|
||||
expect(parsed.node.payload.parentState).toBe(parentStateHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putStateNode — childThread in refs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "wf-cas-nodes-state-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("refs does not include childThread when childThread is null", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const startHash = await cas.put("start");
|
||||
const contentHash = await cas.put("content");
|
||||
const stateHash = await putStateNode(cas, {
|
||||
role: "planner",
|
||||
meta: {},
|
||||
start: startHash,
|
||||
content: contentHash,
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1000,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const blob = await cas.get(stateHash);
|
||||
expect(blob).not.toBeNull();
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
expect(parsed?.kind).toBe("state");
|
||||
if (parsed?.kind !== "state") return;
|
||||
|
||||
expect(parsed.node.refs).not.toContain("anything-else");
|
||||
expect(parsed.node.refs).toEqual([startHash, contentHash]);
|
||||
expect(parsed.node.payload.childThread).toBeNull();
|
||||
});
|
||||
|
||||
test("refs includes childThread hash when childThread is set", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const startHash = await cas.put("start");
|
||||
const contentHash = await cas.put("content");
|
||||
const childEndHash = await cas.put("child-end-state");
|
||||
const stateHash = await putStateNode(cas, {
|
||||
role: "developer",
|
||||
meta: { pr: 42 },
|
||||
start: startHash,
|
||||
content: contentHash,
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 2000,
|
||||
childThread: childEndHash,
|
||||
});
|
||||
|
||||
const blob = await cas.get(stateHash);
|
||||
expect(blob).not.toBeNull();
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
expect(parsed?.kind).toBe("state");
|
||||
if (parsed?.kind !== "state") return;
|
||||
|
||||
expect(parsed.node.refs).toContain(childEndHash);
|
||||
expect(parsed.node.payload.childThread).toBe(childEndHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCasThreadNode — legacy node compatibility", () => {
|
||||
test("start node without parentState field defaults to null", () => {
|
||||
const yaml = stringify({
|
||||
type: "start",
|
||||
payload: { name: "demo", hash: "BUNDLEAAAAAAAAA", depth: 0 },
|
||||
refs: ["PROMPTHASH00001"],
|
||||
});
|
||||
const parsed = parseCasThreadNode(yaml);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.kind).toBe("start");
|
||||
if (parsed?.kind !== "start") return;
|
||||
expect(parsed.node.payload.parentState).toBeNull();
|
||||
});
|
||||
|
||||
test("state node without childThread field defaults to null", () => {
|
||||
const yaml = stringify({
|
||||
type: "state",
|
||||
payload: {
|
||||
role: "planner",
|
||||
meta: {},
|
||||
start: "STARTHASH00001",
|
||||
content: "CONTENTHASH0001",
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1000,
|
||||
},
|
||||
refs: ["STARTHASH00001", "CONTENTHASH0001"],
|
||||
});
|
||||
const parsed = parseCasThreadNode(yaml);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.kind).toBe("state");
|
||||
if (parsed?.kind !== "state") return;
|
||||
expect(parsed.node.payload.childThread).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,5 +9,8 @@ export function collectRefs(payload: StateNode["payload"]): string[] {
|
||||
if (payload.compact !== null) {
|
||||
out.push(payload.compact);
|
||||
}
|
||||
if (payload.childThread !== null) {
|
||||
out.push(payload.childThread);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ function isStartPayload(value: unknown): value is StartNodePayload {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const parentState = value.parentState;
|
||||
if (parentState !== undefined && parentState !== null && typeof parentState !== "string") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof value.name === "string" &&
|
||||
typeof value.hash === "string" &&
|
||||
@@ -25,6 +29,16 @@ function isStartPayload(value: unknown): value is StartNodePayload {
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes a raw start payload, defaulting `parentState` to `null` for legacy nodes. */
|
||||
function normalizeStartPayload(raw: StartNodePayload): StartNodePayload {
|
||||
return {
|
||||
name: raw.name,
|
||||
hash: raw.hash,
|
||||
depth: raw.depth,
|
||||
parentState: raw.parentState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isStatePayload(value: unknown): value is StateNodePayload {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
@@ -41,6 +55,10 @@ function isStatePayload(value: unknown): value is StateNodePayload {
|
||||
if (!isRecord(meta)) {
|
||||
return false;
|
||||
}
|
||||
const childThread = value.childThread;
|
||||
if (childThread !== undefined && childThread !== null && typeof childThread !== "string") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
typeof value.start === "string" &&
|
||||
@@ -49,6 +67,20 @@ function isStatePayload(value: unknown): value is StateNodePayload {
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes a raw state payload, defaulting `childThread` to `null` for legacy nodes. */
|
||||
function normalizeStatePayload(raw: StateNodePayload): StateNodePayload {
|
||||
return {
|
||||
role: raw.role,
|
||||
meta: raw.meta,
|
||||
start: raw.start,
|
||||
content: raw.content,
|
||||
ancestors: raw.ancestors,
|
||||
compact: raw.compact,
|
||||
timestamp: raw.timestamp,
|
||||
childThread: raw.childThread ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parses a YAML CAS blob into a typed RFC v3 thread node (or legacy content layout with `children`). */
|
||||
export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null {
|
||||
let raw: unknown;
|
||||
@@ -86,14 +118,14 @@ export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null
|
||||
if (!isStartPayload(raw.payload)) {
|
||||
return null;
|
||||
}
|
||||
const node: StartNode = { type: "start", payload: raw.payload, refs: [...refs] };
|
||||
const node: StartNode = { type: "start", payload: normalizeStartPayload(raw.payload), refs: [...refs] };
|
||||
return { kind: "start", node };
|
||||
}
|
||||
|
||||
if (!isStatePayload(raw.payload)) {
|
||||
return null;
|
||||
}
|
||||
const node: StateNode = { type: "state", payload: raw.payload, refs: [...refs] };
|
||||
const node: StateNode = { type: "state", payload: normalizeStatePayload(raw.payload), refs: [...refs] };
|
||||
return { kind: "state", node };
|
||||
}
|
||||
|
||||
@@ -143,10 +175,14 @@ export async function putStartNode(
|
||||
payload: StartNode["payload"],
|
||||
promptHash: string,
|
||||
): Promise<string> {
|
||||
const refs = [promptHash];
|
||||
if (payload.parentState !== null) {
|
||||
refs.push(payload.parentState);
|
||||
}
|
||||
const node: StartNode = {
|
||||
type: "start",
|
||||
payload,
|
||||
refs: [promptHash],
|
||||
refs,
|
||||
};
|
||||
return store.put(serializeCasNode(node));
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -59,6 +60,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
const c2 = await putContentNodeWithRefs(cas, "c1", []);
|
||||
@@ -70,6 +72,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
ancestors: [h1],
|
||||
compact: null,
|
||||
timestamp: 2,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
const ec = await putContentNodeWithRefs(cas, "", []);
|
||||
@@ -81,6 +84,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
ancestors: [h1],
|
||||
compact: null,
|
||||
timestamp: 3,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
await upsertThreadEntry(bundleDir, "THREAD_AAAAAAA", {
|
||||
|
||||
@@ -112,6 +112,7 @@ async function appendStateForStep(params: {
|
||||
ancestors,
|
||||
compact: null,
|
||||
timestamp: params.timestamp,
|
||||
childThread: null,
|
||||
};
|
||||
const stateHash = await putStateNode(params.cas, payload);
|
||||
return {
|
||||
@@ -137,6 +138,7 @@ async function appendEndState(params: {
|
||||
ancestors,
|
||||
compact: null,
|
||||
timestamp: params.timestamp,
|
||||
childThread: null,
|
||||
};
|
||||
return putStateNode(params.cas, payload);
|
||||
}
|
||||
@@ -439,6 +441,7 @@ export async function executeThread(
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
depth: options.depth,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -240,6 +240,7 @@ async function buildForkContinuation(params: {
|
||||
ancestors: ancestorsMarker,
|
||||
compact: null,
|
||||
timestamp: Date.now(),
|
||||
childThread: null,
|
||||
};
|
||||
const markerHash = await putStateNode(cas, markerPayload);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
depth: number;
|
||||
/** Parent thread's head state hash at spawn time. `null` for top-level workflows. */
|
||||
parentState: string | null;
|
||||
};
|
||||
|
||||
export type StartNode = {
|
||||
@@ -20,6 +22,8 @@ export type StateNodePayload = {
|
||||
ancestors: string[];
|
||||
compact: string | null;
|
||||
timestamp: number;
|
||||
/** Child thread's final state hash (workflow-as-agent). `null` when no child spawned. */
|
||||
childThread: string | null;
|
||||
};
|
||||
|
||||
export type StateNode = {
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("buildThreadContext", () => {
|
||||
const bundleHash = "BHAAAAAAAAAAA";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, depth: 2 },
|
||||
{ name: "demo", hash: bundleHash, depth: 2, parentState: null },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("buildThreadContext", () => {
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1000,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const chCode = await putContentNodeWithRefs(cas, "code body", []);
|
||||
@@ -52,6 +53,7 @@ describe("buildThreadContext", () => {
|
||||
ancestors: [statePlan],
|
||||
compact: null,
|
||||
timestamp: 2000,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const ctx = await buildThreadContext(stateCode, cas);
|
||||
@@ -71,7 +73,7 @@ describe("buildThreadContext", () => {
|
||||
const promptHash = await cas.put("only-prompt");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 },
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1, parentState: null },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -87,7 +89,7 @@ describe("buildThreadContext", () => {
|
||||
const bundleHash = "BHCCCCCCCCCCC";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, depth: 0 },
|
||||
{ name: "demo", hash: bundleHash, depth: 0, parentState: null },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -100,6 +102,7 @@ describe("buildThreadContext", () => {
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 500,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const endContent = await putContentNodeWithRefs(cas, "finished", []);
|
||||
@@ -111,6 +114,7 @@ describe("buildThreadContext", () => {
|
||||
ancestors: [state1],
|
||||
compact: null,
|
||||
timestamp: 600,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const ctx = await buildThreadContext(endState, cas);
|
||||
|
||||
Reference in New Issue
Block a user