feat: Phase 1 — CAS thread storage types + helpers

- Add StartNode, StateNode, ContentMerkleNode types to workflow-protocol
- Add collectRefs() to workflow-cas — extracts CAS hashes from StateNode payload
- Add findReachableHashes() to workflow-cas — recursive mark traversal via refs[]
- Tests: 7 pass (collect-refs + reachable)

Refs #155, closes #156

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-09 07:30:47 +00:00
parent 8f78a00063
commit 6f000512d2
9 changed files with 280 additions and 34 deletions
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import type { StateNode } from "@uncaged/workflow-protocol";
import { collectRefs } from "../src/collect-refs.js";
function payload(
partial: Partial<StateNode["payload"]> & Pick<StateNode["payload"], "role">,
): StateNode["payload"] {
return {
role: partial.role,
meta: partial.meta ?? {},
start: partial.start ?? "STARTHASH000000000000001",
content: partial.content ?? "CONTENTHASH00000000000001",
ancestors: partial.ancestors ?? [],
compact: partial.compact ?? null,
timestamp: partial.timestamp ?? 0,
};
}
describe("collectRefs", () => {
test("collects start, content, ancestors, and compact hashes in order", () => {
const refs = collectRefs(
payload({
role: "coder",
start: "01START00000000000000001",
content: "01CONTENT0000000000000001",
ancestors: ["01PARENT0000000000000001", "01GRAND000000000000000001"],
compact: "01COMPACT0000000000000001",
}),
);
expect(refs).toEqual([
"01START00000000000000001",
"01CONTENT0000000000000001",
"01PARENT0000000000000001",
"01GRAND000000000000000001",
"01COMPACT0000000000000001",
]);
});
test("does not collect compact when compact is null", () => {
const refs = collectRefs(
payload({
role: "coder",
start: "S1",
content: "C1",
ancestors: ["A1"],
compact: null,
}),
);
expect(refs).toEqual(["S1", "C1", "A1"]);
});
test("returns only start and content when ancestors is empty", () => {
const refs = collectRefs(
payload({
role: "coder",
start: "S2",
content: "C2",
ancestors: [],
compact: null,
}),
);
expect(refs).toEqual(["S2", "C2"]);
});
});
@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
import type { CasStore } from "@uncaged/workflow-protocol";
import { stringify } from "yaml";
import { findReachableHashes } from "../src/reachable.js";
function yamlBlob(refs: readonly string[]): string {
return stringify({ type: "node", payload: {}, refs: [...refs] }, { indent: 2 });
}
function memoryCas(entries: Record<string, string>): CasStore {
const map = { ...entries };
return {
async put(): Promise<string> {
throw new Error("memoryCas.put not used in tests");
},
async get(hash: string): Promise<string | null> {
return map[hash] ?? null;
},
async delete(): Promise<void> {},
async list(): Promise<string[]> {
return Object.keys(map);
},
};
}
describe("findReachableHashes", () => {
test("walks refs recursively from a single root", async () => {
const cas = memoryCas({
R1: yamlBlob(["R2"]),
R2: yamlBlob(["R3"]),
R3: yamlBlob([]),
});
const reachable = await findReachableHashes(["R1"], cas);
expect([...reachable].sort()).toEqual(["R1", "R2", "R3"]);
});
test("union of reachability from multiple roots", async () => {
const cas = memoryCas({
A: yamlBlob(["X"]),
B: yamlBlob(["Y"]),
X: yamlBlob([]),
Y: yamlBlob(["Z"]),
Z: yamlBlob([]),
});
const reachable = await findReachableHashes(["A", "B"], cas);
expect([...reachable].sort()).toEqual(["A", "B", "X", "Y", "Z"]);
});
test("handles cycles via visited set", async () => {
const cas = memoryCas({
C1: yamlBlob(["C2"]),
C2: yamlBlob(["C1"]),
});
const reachable = await findReachableHashes(["C1"], cas);
expect(reachable.size).toBe(2);
expect(reachable.has("C1")).toBe(true);
expect(reachable.has("C2")).toBe(true);
});
test("does not throw when a ref points to a missing blob", async () => {
const cas = memoryCas({
H1: yamlBlob(["MISSINGHASH0000000000001"]),
});
const reachable = await findReachableHashes(["H1"], cas);
expect(reachable.has("H1")).toBe(true);
expect(reachable.has("MISSINGHASH0000000000001")).toBe(false);
});
});
+3
View File
@@ -2,6 +2,9 @@
"name": "@uncaged/workflow-cas",
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "bun test"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
+13
View File
@@ -0,0 +1,13 @@
import type { StateNode } from "@uncaged/workflow-protocol";
/** Collects CAS hashes from {@link StateNode} payload fields for GC `refs[]` derivation. */
export function collectRefs(payload: StateNode["payload"]): string[] {
const out: string[] = [payload.start, payload.content];
for (const h of payload.ancestors) {
out.push(h);
}
if (payload.compact !== null) {
out.push(payload.compact);
}
return out;
}
+2
View File
@@ -1,4 +1,5 @@
export { createCasStore } from "./cas.js";
export { collectRefs } from "./collect-refs.js";
export { hashString, hashWorkflowBundleBytes } from "./hash.js";
export {
createContentMerkleNode,
@@ -9,6 +10,7 @@ export {
putThreadMerkleNode,
serializeMerkleNode,
} from "./merkle.js";
export { findReachableHashes } from "./reachable.js";
export type {
CasStore,
MerkleNode,
+55
View File
@@ -0,0 +1,55 @@
import { parse } from "yaml";
import type { CasStore } from "./types.js";
function refsFromBlob(content: string): string[] {
try {
const raw = parse(content) as unknown;
if (raw === null || typeof raw !== "object") {
return [];
}
const rec = raw as Record<string, unknown>;
const refs = rec.refs;
if (!Array.isArray(refs)) {
return [];
}
const out: string[] = [];
for (const r of refs) {
if (typeof r === "string") {
out.push(r);
}
}
return out;
} catch {
return [];
}
}
/** Recursively collects all CAS hashes reachable from `roots` via each blob's `refs[]`. */
export async function findReachableHashes(
roots: readonly string[],
cas: CasStore,
): Promise<ReadonlySet<string>> {
const visited = new Set<string>();
const stack = [...roots];
while (stack.length > 0) {
const hash = stack.pop();
if (hash === undefined) {
break;
}
if (visited.has(hash)) {
continue;
}
const blob = await cas.get(hash);
if (blob === null) {
continue;
}
visited.add(hash);
for (const ref of refsFromBlob(blob)) {
if (!visited.has(ref)) {
stack.push(ref);
}
}
}
return visited;
}
+1 -4
View File
@@ -5,8 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-util" }
]
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
}
@@ -0,0 +1,36 @@
// ── CAS thread chain nodes (RFC: CAS-based thread storage) ──────────
export type StartNodePayload = {
name: string;
hash: string;
maxRounds: number;
depth: number;
};
export type StartNode = {
type: "start";
payload: StartNodePayload;
refs: string[];
};
export type StateNodePayload = {
role: string;
meta: Record<string, unknown>;
start: string;
content: string;
ancestors: string[];
compact: string | null;
timestamp: number;
};
export type StateNode = {
type: "state";
payload: StateNodePayload;
refs: string[];
};
export type ContentMerkleNode = {
type: "content";
payload: string;
refs: string[];
};
+36 -30
View File
@@ -1,40 +1,46 @@
// ── Types ──────────────────────────────────────────────────────────
export type {
Result,
CasStore,
WorkflowRoleSchema,
WorkflowRoleDescriptor,
WorkflowDescriptor,
RoleMeta,
RoleOutput,
StartStep,
RoleStep,
ThreadContext,
ModeratorContext,
AgentContext,
ExtractContext,
WorkflowCompletion,
WorkflowResult,
LlmProvider,
ProviderConfig,
ResolvedModel,
WorkflowConfig,
ExtractFn,
AgentFn,
AgentBinding,
WorkflowRuntime,
WorkflowFn,
RoleDefinition,
Moderator,
WorkflowDefinition,
AdvanceOutcome,
ContentMerkleNode,
StartNode,
StateNode,
} from "./cas-types.js";
export type {
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
CasStore,
ExtractContext,
ExtractFn,
LlmProvider,
Moderator,
ModeratorContext,
ProviderConfig,
ResolvedModel,
Result,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowConfig,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
} from "./types.js";
// ── Constants ──────────────────────────────────────────────────────
export { START, END } from "./types.js";
export { END, START } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { ok, err } from "./result.js";
export { err, ok } from "./result.js";