feat(#194): Phase 1 — parentState / childThread in CAS nodes
- Protocol: StartNodePayload.parentState, StateNodePayload.childThread - CAS: putStartNode refs include parentState, collectRefs includes childThread - Parsing: legacy nodes without new fields default to null - Engine + fork: all callers pass parentState: null / childThread: null - Tests: 8 new cases for refs, parsing, collect-refs (+208 lines) Phase 1 of #194 (Merkle Call Stack). Closes #195. 小橘 <xiaoju@shazhou.work>
This commit is contained in:
@@ -45,8 +45,8 @@ describe("gc cli and garbageCollectCas", () => {
|
|||||||
{
|
{
|
||||||
name: "demo",
|
name: "demo",
|
||||||
hash: bundleHash,
|
hash: bundleHash,
|
||||||
|
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
parentState: null,
|
||||||
},
|
},
|
||||||
promptHash,
|
promptHash,
|
||||||
);
|
);
|
||||||
@@ -100,8 +100,8 @@ describe("gc cli and garbageCollectCas", () => {
|
|||||||
{
|
{
|
||||||
name: "demo",
|
name: "demo",
|
||||||
hash: bundleHash,
|
hash: bundleHash,
|
||||||
|
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
parentState: null,
|
||||||
},
|
},
|
||||||
promptHash,
|
promptHash,
|
||||||
);
|
);
|
||||||
@@ -135,8 +135,8 @@ describe("gc cli and garbageCollectCas", () => {
|
|||||||
{
|
{
|
||||||
name: "demo",
|
name: "demo",
|
||||||
hash: bundleHash,
|
hash: bundleHash,
|
||||||
|
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
parentState: null,
|
||||||
},
|
},
|
||||||
promptHash,
|
promptHash,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function payload(
|
|||||||
ancestors: partial.ancestors ?? [],
|
ancestors: partial.ancestors ?? [],
|
||||||
compact: partial.compact ?? null,
|
compact: partial.compact ?? null,
|
||||||
timestamp: partial.timestamp ?? 0,
|
timestamp: partial.timestamp ?? 0,
|
||||||
|
childThread: partial.childThread ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,4 +63,32 @@ describe("collectRefs", () => {
|
|||||||
);
|
);
|
||||||
expect(refs).toEqual(["S2", "C2"]);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
|||||||
name: "demo",
|
name: "demo",
|
||||||
hash: bundleHash,
|
hash: bundleHash,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
parentState: null,
|
||||||
},
|
},
|
||||||
promptHash,
|
promptHash,
|
||||||
);
|
);
|
||||||
@@ -59,6 +60,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
|||||||
ancestors: [],
|
ancestors: [],
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
childThread: null,
|
||||||
} satisfies StateNodePayload);
|
} satisfies StateNodePayload);
|
||||||
|
|
||||||
const c2 = await putContentNodeWithRefs(cas, "c1", []);
|
const c2 = await putContentNodeWithRefs(cas, "c1", []);
|
||||||
@@ -70,6 +72,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
|||||||
ancestors: [h1],
|
ancestors: [h1],
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
|
childThread: null,
|
||||||
} satisfies StateNodePayload);
|
} satisfies StateNodePayload);
|
||||||
|
|
||||||
const ec = await putContentNodeWithRefs(cas, "", []);
|
const ec = await putContentNodeWithRefs(cas, "", []);
|
||||||
@@ -81,6 +84,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
|||||||
ancestors: [h1],
|
ancestors: [h1],
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
|
childThread: null,
|
||||||
} satisfies StateNodePayload);
|
} satisfies StateNodePayload);
|
||||||
|
|
||||||
await upsertThreadEntry(bundleDir, "THREAD_AAAAAAA", {
|
await upsertThreadEntry(bundleDir, "THREAD_AAAAAAA", {
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ async function appendStateForStep(params: {
|
|||||||
ancestors,
|
ancestors,
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: params.timestamp,
|
timestamp: params.timestamp,
|
||||||
|
childThread: null,
|
||||||
};
|
};
|
||||||
const stateHash = await putStateNode(params.cas, payload);
|
const stateHash = await putStateNode(params.cas, payload);
|
||||||
return {
|
return {
|
||||||
@@ -137,6 +138,7 @@ async function appendEndState(params: {
|
|||||||
ancestors,
|
ancestors,
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: params.timestamp,
|
timestamp: params.timestamp,
|
||||||
|
childThread: null,
|
||||||
};
|
};
|
||||||
return putStateNode(params.cas, payload);
|
return putStateNode(params.cas, payload);
|
||||||
}
|
}
|
||||||
@@ -439,6 +441,7 @@ export async function executeThread(
|
|||||||
name: workflowName,
|
name: workflowName,
|
||||||
hash: io.hash,
|
hash: io.hash,
|
||||||
depth: options.depth,
|
depth: options.depth,
|
||||||
|
parentState: null,
|
||||||
},
|
},
|
||||||
promptHash,
|
promptHash,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ async function buildForkContinuation(params: {
|
|||||||
ancestors: ancestorsMarker,
|
ancestors: ancestorsMarker,
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
childThread: null,
|
||||||
};
|
};
|
||||||
const markerHash = await putStateNode(cas, markerPayload);
|
const markerHash = await putStateNode(cas, markerPayload);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("buildThreadContext", () => {
|
|||||||
const bundleHash = "BHAAAAAAAAAAA";
|
const bundleHash = "BHAAAAAAAAAAA";
|
||||||
const startHash = await putStartNode(
|
const startHash = await putStartNode(
|
||||||
cas,
|
cas,
|
||||||
{ name: "demo", hash: bundleHash, depth: 2 },
|
{ name: "demo", hash: bundleHash, depth: 2, parentState: null },
|
||||||
promptHash,
|
promptHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ describe("buildThreadContext", () => {
|
|||||||
ancestors: [],
|
ancestors: [],
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: 1000,
|
timestamp: 1000,
|
||||||
|
childThread: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chCode = await putContentNodeWithRefs(cas, "code body", []);
|
const chCode = await putContentNodeWithRefs(cas, "code body", []);
|
||||||
@@ -52,6 +53,7 @@ describe("buildThreadContext", () => {
|
|||||||
ancestors: [statePlan],
|
ancestors: [statePlan],
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: 2000,
|
timestamp: 2000,
|
||||||
|
childThread: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = await buildThreadContext(stateCode, cas);
|
const ctx = await buildThreadContext(stateCode, cas);
|
||||||
@@ -71,7 +73,7 @@ describe("buildThreadContext", () => {
|
|||||||
const promptHash = await cas.put("only-prompt");
|
const promptHash = await cas.put("only-prompt");
|
||||||
const startHash = await putStartNode(
|
const startHash = await putStartNode(
|
||||||
cas,
|
cas,
|
||||||
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 },
|
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1, parentState: null },
|
||||||
promptHash,
|
promptHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ describe("buildThreadContext", () => {
|
|||||||
const bundleHash = "BHCCCCCCCCCCC";
|
const bundleHash = "BHCCCCCCCCCCC";
|
||||||
const startHash = await putStartNode(
|
const startHash = await putStartNode(
|
||||||
cas,
|
cas,
|
||||||
{ name: "demo", hash: bundleHash, depth: 0 },
|
{ name: "demo", hash: bundleHash, depth: 0, parentState: null },
|
||||||
promptHash,
|
promptHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,6 +102,7 @@ describe("buildThreadContext", () => {
|
|||||||
ancestors: [],
|
ancestors: [],
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: 500,
|
timestamp: 500,
|
||||||
|
childThread: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const endContent = await putContentNodeWithRefs(cas, "finished", []);
|
const endContent = await putContentNodeWithRefs(cas, "finished", []);
|
||||||
@@ -111,6 +114,7 @@ describe("buildThreadContext", () => {
|
|||||||
ancestors: [state1],
|
ancestors: [state1],
|
||||||
compact: null,
|
compact: null,
|
||||||
timestamp: 600,
|
timestamp: 600,
|
||||||
|
childThread: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = await buildThreadContext(endState, cas);
|
const ctx = await buildThreadContext(endState, cas);
|
||||||
|
|||||||
Reference in New Issue
Block a user