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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@
|
||||
"name": "@uncaged/workflow-cas",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user