Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e01c08dacb | |||
| f9d3d38008 | |||
| 9e99e58405 | |||
| 6af3059fb4 | |||
| 0da1aabfab | |||
| ebfb99bf4c | |||
| 33f9425848 | |||
| 2b707fb44e | |||
| 6306b23a9f | |||
| 6bb8cf8315 | |||
| 93b7947d7c | |||
| 9584a86fb7 | |||
| defc0afc27 | |||
| 9f6633d5bf | |||
| 7dadf874e1 | |||
| ba90214af6 |
@@ -4,6 +4,10 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"overrides": {
|
||||
"@uncaged/json-cas": "file:../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../json-cas/packages/json-cas-workflow"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
|
||||
@@ -20,9 +20,6 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put(input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
@@ -155,10 +152,9 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put( input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
@@ -197,9 +193,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -228,9 +224,9 @@ export const run = async function* (input, options) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -261,9 +257,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -284,16 +280,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -326,16 +322,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -378,9 +374,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -391,9 +387,9 @@ export const run = async function* (input, options) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
@@ -446,9 +442,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -463,9 +459,9 @@ export const run = async function* (input, options) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
export const descriptor = {
|
||||
const threeRoleBundleSource = `export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
@@ -30,16 +28,16 @@ export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
const h = await cas.put( "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
const h = await cas.put( "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, body);
|
||||
const h = await cas.put( body);
|
||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
|
||||
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
roles: {
|
||||
@@ -41,25 +38,23 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -68,37 +63,34 @@ export const run = async function* (input, options) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
let h = await cas.put( "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
h = await cas.put( "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
|
||||
@@ -51,7 +51,6 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -249,8 +249,7 @@ Each role has:
|
||||
|-------|------|---------|
|
||||
| \`description\` | string | What the role does |
|
||||
| \`systemPrompt\` | string | System prompt for the agent |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CursorAgentConfig } from "./types.js";
|
||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
export type { CursorAgentConfig } from "./types.js";
|
||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
function throwCursorSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
|
||||
@@ -14,6 +14,7 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||
type HermesAgentOpt = { prompt: string };
|
||||
|
||||
export type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
|
||||
@@ -53,6 +53,35 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
return states;
|
||||
}
|
||||
|
||||
function isClickableGraphNode(nodeStates: Map<string, NodeState>, nodeId: string): boolean {
|
||||
const state = nodeStates.get(nodeId);
|
||||
return state !== undefined && state !== "default";
|
||||
}
|
||||
|
||||
function scrollToFirstRecord(): void {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function scrollToRoleOccurrence(
|
||||
nodeId: string,
|
||||
indicesByRole: Map<string, number[]>,
|
||||
clickCycleRef: { current: Map<string, number> },
|
||||
onHighlight: (role: string) => void,
|
||||
): void {
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
|
||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||
const idx = indices[cycle % indices.length];
|
||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el === null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
onHighlight(nodeId);
|
||||
}
|
||||
|
||||
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
const sse = useSSE(client, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
||||
@@ -96,44 +125,29 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
// Track which occurrence to jump to next per role (cycling)
|
||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const highlightRole = useCallback((role: string) => {
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(role);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
// Only allow clicks on lit (non-default) nodes
|
||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||
|
||||
// __start__: scroll to the first record (thread-start prompt)
|
||||
if (!isClickableGraphNode(nodeStates, nodeId)) return;
|
||||
if (nodeId === "__start__") {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
scrollToFirstRecord();
|
||||
return;
|
||||
}
|
||||
|
||||
// __end__: scroll to bottom
|
||||
if (nodeId === "__end__") {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Role nodes: cycle through occurrences
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
|
||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||
const idx = indices[cycle % indices.length];
|
||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el !== null) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(nodeId);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
|
||||
},
|
||||
[nodeStates, indicesByRole],
|
||||
[nodeStates, indicesByRole, highlightRole],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,67 +39,119 @@ function resolveType(prop: Record<string, unknown>): string {
|
||||
return String(prop.type ?? "unknown");
|
||||
}
|
||||
|
||||
function variantLabel(
|
||||
variantProps: Record<string, Record<string, unknown>>,
|
||||
variantIndex: number,
|
||||
): string {
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`;
|
||||
}
|
||||
return `Variant ${variantIndex + 1}`;
|
||||
}
|
||||
|
||||
function childPrefixForDepth(depth: number, parentPrefix: string): string {
|
||||
return depth > 0 ? `${parentPrefix} ` : " ";
|
||||
}
|
||||
|
||||
function flattenOneOfVariants(
|
||||
oneOf: Array<Record<string, unknown>>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||
const variantRequired = new Set<string>(
|
||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||
);
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) continue;
|
||||
rows.push(
|
||||
...flattenProperty(
|
||||
pName,
|
||||
pDef,
|
||||
depth + 1,
|
||||
variantChildPrefix,
|
||||
`${keyPrefix}v${vi}-`,
|
||||
variantRequired,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenSchemaProperties(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
const rows: SchemaRow[] = [];
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenSchema(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
|
||||
// Handle oneOf / discriminatedUnion
|
||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
let variantLabel = `Variant ${vi + 1}`;
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) {
|
||||
variantLabel = `${pName}: ${String(pDef.const)}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||
const variantRequired = new Set<string>(
|
||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||
);
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) continue;
|
||||
const subRows = flattenProperty(
|
||||
pName,
|
||||
pDef,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}v${vi}-`,
|
||||
variantRequired,
|
||||
);
|
||||
rows.push(...subRows);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
|
||||
}
|
||||
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
|
||||
}
|
||||
|
||||
function flattenNestedPropertyRows(
|
||||
name: string,
|
||||
prop: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
hasOneOf: boolean,
|
||||
): SchemaRow[] {
|
||||
const childPrefix = childPrefixForDepth(depth, parentPrefix);
|
||||
const nestedKeyPrefix = `${keyPrefix}${name}-`;
|
||||
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
||||
rows.push(...subRows);
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
|
||||
if (hasOneOf) {
|
||||
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function flattenProperty(
|
||||
@@ -110,55 +162,23 @@ function flattenProperty(
|
||||
keyPrefix: string,
|
||||
required: Set<string>,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||
if (!required.has(name)) type += "?";
|
||||
const description = String(prop.description ?? "");
|
||||
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
||||
|
||||
rows.push({
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: displayName,
|
||||
type,
|
||||
description,
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
});
|
||||
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(
|
||||
...flattenSchema(
|
||||
prop as Record<string, unknown>,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}${name}-`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`));
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(
|
||||
...flattenSchema(
|
||||
prop as Record<string, unknown>,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}${name}-`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows: SchemaRow[] = [
|
||||
{
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
|
||||
type,
|
||||
description: String(prop.description ?? ""),
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
},
|
||||
];
|
||||
|
||||
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,128 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
|
||||
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function detectBackEdges(ids: Set<string>, edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const backEdges = new Set<string>();
|
||||
const color = new Map<string, number>();
|
||||
for (const id of ids) color.set(id, WHITE);
|
||||
|
||||
const fullAdj = new Map<string, string[]>();
|
||||
for (const id of ids) fullAdj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
|
||||
}
|
||||
|
||||
function dfs(u: string): void {
|
||||
color.set(u, GRAY);
|
||||
for (const v of fullAdj.get(u) ?? []) {
|
||||
const c = color.get(v) ?? WHITE;
|
||||
if (c === GRAY) {
|
||||
backEdges.add(`${u}->${v}`);
|
||||
} else if (c === WHITE) {
|
||||
dfs(v);
|
||||
}
|
||||
}
|
||||
color.set(u, BLACK);
|
||||
}
|
||||
|
||||
if (ids.has(START_ID)) dfs(START_ID);
|
||||
for (const id of ids) {
|
||||
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||
}
|
||||
return backEdges;
|
||||
}
|
||||
|
||||
function buildDagAdjacency(
|
||||
ids: Set<string>,
|
||||
edges: readonly WorkflowGraphEdge[],
|
||||
backEdges: Set<string>,
|
||||
): Map<string, string[]> {
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const id of ids) adj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
if (backEdges.has(`${e.from}->${e.to}`)) continue;
|
||||
adj.get(e.from)?.push(e.to);
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
function computeInDegrees(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
||||
const inDegree = new Map<string, number>();
|
||||
for (const id of ids) inDegree.set(id, 0);
|
||||
for (const id of ids) {
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return inDegree;
|
||||
}
|
||||
|
||||
function relaxLongestPathNeighbors(
|
||||
cur: string,
|
||||
curRank: number,
|
||||
adj: Map<string, string[]>,
|
||||
rank: Map<string, number>,
|
||||
inDegree: Map<string, number>,
|
||||
queue: string[],
|
||||
): void {
|
||||
for (const next of adj.get(cur) ?? []) {
|
||||
const prevRank = rank.get(next) ?? 0;
|
||||
if (curRank + 1 > prevRank) rank.set(next, curRank + 1);
|
||||
const deg = (inDegree.get(next) ?? 1) - 1;
|
||||
inDegree.set(next, deg);
|
||||
if (deg === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
function longestPathRanks(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
||||
const inDegree = computeInDegrees(ids, adj);
|
||||
const rank = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
for (const id of ids) {
|
||||
if ((inDegree.get(id) ?? 0) === 0) {
|
||||
queue.push(id);
|
||||
rank.set(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift();
|
||||
if (cur === undefined) break;
|
||||
relaxLongestPathNeighbors(cur, rank.get(cur) ?? 0, adj, rank, inDegree, queue);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
function compareLayerNodes(a: string, b: string): number {
|
||||
if (a === START_ID) return -1;
|
||||
if (b === START_ID) return 1;
|
||||
if (a === END_ID) return 1;
|
||||
if (b === END_ID) return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function ranksToLayers(rank: Map<string, number>): string[][] {
|
||||
const maxRank = Math.max(...[...rank.values()], 0);
|
||||
const layers: string[][] = [];
|
||||
for (let r = 0; r <= maxRank; r++) layers.push([]);
|
||||
for (const [id, r] of rank) layers[r].push(id);
|
||||
for (const layer of layers) layer.sort(compareLayerNodes);
|
||||
return layers.filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
// ── Strategy 1: Longest-path layering (Sugiyama step 1) ─────────────
|
||||
|
||||
/**
|
||||
@@ -49,123 +171,11 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
||||
* on the resulting DAG, then the removed edges become feedback edges.
|
||||
*/
|
||||
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
||||
// Collect all node IDs
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
|
||||
// Build adjacency (excluding self-loops)
|
||||
const adj = new Map<string, string[]>();
|
||||
const inEdges = new Map<string, string[]>();
|
||||
for (const id of ids) {
|
||||
adj.set(id, []);
|
||||
inEdges.set(id, []);
|
||||
}
|
||||
// Detect back-edges via DFS to break cycles
|
||||
const backEdges = new Set<string>();
|
||||
{
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
for (const id of ids) color.set(id, WHITE);
|
||||
|
||||
// Temporary full adjacency for cycle detection
|
||||
const fullAdj = new Map<string, string[]>();
|
||||
for (const id of ids) fullAdj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
|
||||
}
|
||||
|
||||
function dfs(u: string): void {
|
||||
color.set(u, GRAY);
|
||||
for (const v of fullAdj.get(u) ?? []) {
|
||||
const c = color.get(v) ?? WHITE;
|
||||
if (c === GRAY) {
|
||||
// Back-edge: u -> v where v is an ancestor
|
||||
backEdges.add(`${u}->${v}`);
|
||||
} else if (c === WHITE) {
|
||||
dfs(v);
|
||||
}
|
||||
}
|
||||
color.set(u, BLACK);
|
||||
}
|
||||
|
||||
// Start DFS from __start__ first for determinism
|
||||
if (ids.has(START_ID)) dfs(START_ID);
|
||||
for (const id of ids) {
|
||||
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Build DAG adjacency (without back-edges)
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
if (backEdges.has(`${e.from}->${e.to}`)) continue;
|
||||
adj.get(e.from)?.push(e.to);
|
||||
inEdges.get(e.to)?.push(e.from);
|
||||
}
|
||||
|
||||
// Longest-path ranking via topological order (Kahn's algorithm)
|
||||
const inDegree = new Map<string, number>();
|
||||
for (const id of ids) inDegree.set(id, 0);
|
||||
for (const id of ids) {
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const rank = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
for (const id of ids) {
|
||||
if ((inDegree.get(id) ?? 0) === 0) {
|
||||
queue.push(id);
|
||||
rank.set(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
const curRank = rank.get(cur) ?? 0;
|
||||
for (const next of adj.get(cur) ?? []) {
|
||||
// Longest path: take max
|
||||
const prevRank = rank.get(next) ?? 0;
|
||||
if (curRank + 1 > prevRank) {
|
||||
rank.set(next, curRank + 1);
|
||||
}
|
||||
const deg = (inDegree.get(next) ?? 1) - 1;
|
||||
inDegree.set(next, deg);
|
||||
if (deg === 0) {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by rank
|
||||
const maxRank = Math.max(...[...rank.values()], 0);
|
||||
const layers: string[][] = [];
|
||||
for (let r = 0; r <= maxRank; r++) {
|
||||
layers.push([]);
|
||||
}
|
||||
for (const [id, r] of rank) {
|
||||
layers[r].push(id);
|
||||
}
|
||||
|
||||
// Sort within layers alphabetically for stability, but __start__ first, __end__ last
|
||||
for (const layer of layers) {
|
||||
layer.sort((a, b) => {
|
||||
if (a === START_ID) return -1;
|
||||
if (b === START_ID) return 1;
|
||||
if (a === END_ID) return 1;
|
||||
if (b === END_ID) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove empty layers
|
||||
return layers.filter((l) => l.length > 0);
|
||||
const ids = collectNodeIds(edges);
|
||||
const backEdges = detectBackEdges(ids, edges);
|
||||
const adj = buildDagAdjacency(ids, edges, backEdges);
|
||||
const rank = longestPathRanks(ids, adj);
|
||||
return ranksToLayers(rank);
|
||||
}
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────
|
||||
@@ -201,132 +211,164 @@ function buildTerminalNode(
|
||||
};
|
||||
}
|
||||
|
||||
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||
type EdgeLayoutContext = {
|
||||
rank: Map<string, number>;
|
||||
nodePositions: Map<string, { x: number; y: number; w: number; h: number }>;
|
||||
centerX: number;
|
||||
routedCountByTarget: Map<string, number>;
|
||||
};
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy
|
||||
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||
const layers = computeLayersLongestPath(input.edges);
|
||||
function computeEdgeLabelPosition(
|
||||
e: WorkflowGraphEdge,
|
||||
ctx: EdgeLayoutContext,
|
||||
isFeedback: boolean,
|
||||
isSkipForward: boolean,
|
||||
isSelfLoop: boolean,
|
||||
): { labelX: number | null; labelY: number | null; feedbackSide: "right" | "left" | null } {
|
||||
const sourcePos = ctx.nodePositions.get(e.from);
|
||||
const targetPos = ctx.nodePositions.get(e.to);
|
||||
if (sourcePos === undefined || targetPos === undefined) {
|
||||
return { labelX: null, labelY: null, feedbackSide: null };
|
||||
}
|
||||
|
||||
// Flatten layers into a rank map (layer index = rank)
|
||||
if (isFeedback || isSkipForward) {
|
||||
const count = ctx.routedCountByTarget.get(e.to) ?? 0;
|
||||
ctx.routedCountByTarget.set(e.to, count + 1);
|
||||
const feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
? ctx.centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||
: ctx.centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
return { labelX: offsetX, labelY: midY, feedbackSide };
|
||||
}
|
||||
|
||||
if (isSelfLoop) {
|
||||
return { labelX: null, labelY: null, feedbackSide: null };
|
||||
}
|
||||
|
||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||
return { labelX: ctx.centerX, labelY: midY, feedbackSide: null };
|
||||
}
|
||||
|
||||
function buildConditionEdge(e: WorkflowGraphEdge, ctx: EdgeLayoutContext): Edge {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
const sourceRank = ctx.rank.get(e.from) ?? 0;
|
||||
const targetRank = ctx.rank.get(e.to) ?? 0;
|
||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
||||
const routed = isFeedback || isSkipForward;
|
||||
|
||||
const { labelX, labelY, feedbackSide } = computeEdgeLabelPosition(
|
||||
e,
|
||||
ctx,
|
||||
isFeedback,
|
||||
isSkipForward,
|
||||
isSelfLoop,
|
||||
);
|
||||
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle: routed ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
|
||||
targetHandle: routed ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback: routed,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LAYER_H_GAP = 40;
|
||||
|
||||
type NodePosition = { x: number; y: number; w: number; h: number };
|
||||
|
||||
function layerIndexRank(layers: string[][]): Map<string, number> {
|
||||
const rank = new Map<string, number>();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
for (const id of layers[i]) {
|
||||
rank.set(id, i);
|
||||
}
|
||||
for (const id of layers[i]) rank.set(id, i);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
// Horizontal gap between nodes in the same layer
|
||||
const H_GAP = 40;
|
||||
|
||||
// Position nodes: each layer is a horizontal row
|
||||
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
|
||||
// Find max layer width for centering
|
||||
const layerWidths: number[] = [];
|
||||
for (const layer of layers) {
|
||||
function computeLayerWidths(layers: string[][], hGap: number): number[] {
|
||||
return layers.map((layer) => {
|
||||
let w = 0;
|
||||
for (const id of layer) {
|
||||
w += nodeSize(id).width;
|
||||
}
|
||||
w += (layer.length - 1) * H_GAP;
|
||||
layerWidths.push(w);
|
||||
}
|
||||
const maxLayerWidth = Math.max(...layerWidths, ROLE_NODE_WIDTH);
|
||||
const centerX = maxLayerWidth / 2;
|
||||
for (const id of layer) w += nodeSize(id).width;
|
||||
return w + (layer.length - 1) * hGap;
|
||||
});
|
||||
}
|
||||
|
||||
function layoutNodePositions(
|
||||
layers: string[][],
|
||||
layerWidths: number[],
|
||||
centerX: number,
|
||||
hGap: number,
|
||||
): Map<string, NodePosition> {
|
||||
const nodePositions = new Map<string, NodePosition>();
|
||||
let y = 0;
|
||||
for (let li = 0; li < layers.length; li++) {
|
||||
const layer = layers[li];
|
||||
const totalWidth = layerWidths[li];
|
||||
let x = centerX - totalWidth / 2;
|
||||
let x = centerX - layerWidths[li] / 2;
|
||||
let maxH = 0;
|
||||
for (const id of layer) {
|
||||
const size = nodeSize(id);
|
||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||
x += size.width + H_GAP;
|
||||
x += size.width + hGap;
|
||||
if (size.height > maxH) maxH = size.height;
|
||||
}
|
||||
y += maxH + LAYER_GAP;
|
||||
}
|
||||
return nodePositions;
|
||||
}
|
||||
|
||||
// Build nodes
|
||||
function buildLayoutNodes(
|
||||
layers: string[][],
|
||||
nodePositions: Map<string, NodePosition>,
|
||||
input: LayoutInput,
|
||||
): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
for (const layer of layers) {
|
||||
for (const id of layer) {
|
||||
const pos = nodePositions.get(id);
|
||||
if (pos === undefined) continue;
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
const xy = { x: pos.x, y: pos.y };
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
|
||||
nodes.push(buildTerminalNode(id, xy, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
||||
nodes.push(buildRoleNode(id, xy, input.roles, state));
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Build edges with label positions
|
||||
const routedCountByTarget = new Map<string, number>();
|
||||
const edges: Edge[] = input.edges.map((e) => {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
const sourceRank = rank.get(e.from) ?? 0;
|
||||
const targetRank = rank.get(e.to) ?? 0;
|
||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
||||
|
||||
const sourcePos = nodePositions.get(e.from);
|
||||
const targetPos = nodePositions.get(e.to);
|
||||
|
||||
let labelX: number | null = null;
|
||||
let labelY: number | null = null;
|
||||
let feedbackSide: "right" | "left" | null = null;
|
||||
|
||||
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||
if (isFeedback || isSkipForward) {
|
||||
const count = routedCountByTarget.get(e.to) ?? 0;
|
||||
routedCountByTarget.set(e.to, count + 1);
|
||||
feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
? centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||
: centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
labelX = offsetX;
|
||||
labelY = midY;
|
||||
} else if (!isSelfLoop) {
|
||||
const midX = centerX;
|
||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||
labelX = midX;
|
||||
labelY = midY;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle:
|
||||
isFeedback || isSkipForward
|
||||
? feedbackSide === "left"
|
||||
? "left-out"
|
||||
: "right-out"
|
||||
: "bottom-out",
|
||||
targetHandle:
|
||||
isFeedback || isSkipForward ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback: isFeedback || isSkipForward,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
});
|
||||
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||
|
||||
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||
const layers = computeLayersLongestPath(input.edges);
|
||||
const rank = layerIndexRank(layers);
|
||||
const layerWidths = computeLayerWidths(layers, LAYER_H_GAP);
|
||||
const centerX = Math.max(...layerWidths, ROLE_NODE_WIDTH) / 2;
|
||||
const nodePositions = layoutNodePositions(layers, layerWidths, centerX, LAYER_H_GAP);
|
||||
const nodes = buildLayoutNodes(layers, nodePositions, input);
|
||||
const edgeCtx: EdgeLayoutContext = {
|
||||
rank,
|
||||
nodePositions,
|
||||
centerX,
|
||||
routedCountByTarget: new Map<string, number>(),
|
||||
};
|
||||
const edges: Edge[] = input.edges.map((e) => buildConditionEdge(e, edgeCtx));
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,869 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, type Store, walk } from "@uncaged/json-cas";
|
||||
import {
|
||||
registerWorkflowSchemas,
|
||||
type WorkflowSchemaHashes,
|
||||
type ContentPayload,
|
||||
type ThreadEndPayload,
|
||||
type ThreadStartPayload,
|
||||
type ThreadStepPayload,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import { registerWorkflow, type WorkflowInput } from "@uncaged/workflow-json-def";
|
||||
|
||||
import {
|
||||
buildJsonCasThreadContext,
|
||||
buildJsonCasThreadSnapshot,
|
||||
readContentText,
|
||||
} from "../src/engine/json-cas-context.js";
|
||||
import { executeJsonCasThread } from "../src/engine/json-cas-engine.js";
|
||||
import type {
|
||||
JsonCasAgentFn,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
} from "../src/engine/json-cas-types.js";
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────────────
|
||||
|
||||
const START = "__start__";
|
||||
const END = "__end__";
|
||||
|
||||
const SIMPLE_WORKFLOW: WorkflowInput = {
|
||||
name: "test-simple",
|
||||
description: "A simple two-role workflow for testing",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Plans the work",
|
||||
systemPrompt: "You are a planner.",
|
||||
extractPrompt: "Extract planner output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["plan"],
|
||||
properties: { plan: { type: "string" } },
|
||||
},
|
||||
},
|
||||
coder: {
|
||||
description: "Implements the plan",
|
||||
systemPrompt: "You are a coder.",
|
||||
extractPrompt: "Extract coder output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["code"],
|
||||
properties: { code: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: START, to: "planner", when: null },
|
||||
{ from: "planner", to: "coder", when: null },
|
||||
{ from: "coder", to: END, when: null },
|
||||
],
|
||||
};
|
||||
|
||||
const SINGLE_ROLE_WORKFLOW: WorkflowInput = {
|
||||
name: "test-single",
|
||||
description: "A single-role workflow",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Does all the work",
|
||||
systemPrompt: "You are a worker.",
|
||||
extractPrompt: "Extract worker output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["result"],
|
||||
properties: { result: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: START, to: "worker", when: null },
|
||||
{ from: "worker", to: END, when: null },
|
||||
],
|
||||
};
|
||||
|
||||
const CONDITIONAL_WORKFLOW: WorkflowInput = {
|
||||
name: "test-conditional",
|
||||
description: "A workflow with JSONata conditions",
|
||||
roles: {
|
||||
checker: {
|
||||
description: "Checks the input",
|
||||
systemPrompt: "You are a checker.",
|
||||
extractPrompt: "Extract checker output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["status"],
|
||||
properties: { status: { type: "string" } },
|
||||
},
|
||||
},
|
||||
fixer: {
|
||||
description: "Fixes issues",
|
||||
systemPrompt: "You are a fixer.",
|
||||
extractPrompt: "Extract fixer output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["fix"],
|
||||
properties: { fix: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: START, to: "checker", when: null },
|
||||
{ from: "checker", to: END, when: "steps[-1].meta.status = 'ok'" },
|
||||
{ from: "checker", to: "fixer", when: null },
|
||||
{ from: "fixer", to: "checker", when: null },
|
||||
],
|
||||
};
|
||||
|
||||
function noLogger(): (tag: string, content: string) => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async function setupStore(): Promise<{
|
||||
store: Store;
|
||||
typeHashes: WorkflowSchemaHashes;
|
||||
}> {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
return { store, typeHashes };
|
||||
}
|
||||
|
||||
async function setupWorkflow(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
workflowDef: WorkflowInput,
|
||||
) {
|
||||
const workflowHash = await registerWorkflow(store, typeHashes, workflowDef);
|
||||
return { workflowHash };
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<JsonCasEngineOptions> = {}): JsonCasEngineOptions {
|
||||
return {
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
signal: new AbortController().signal,
|
||||
agents: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIo(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
threadId: string,
|
||||
): JsonCasEngineIo {
|
||||
return { threadId, store, typeHashes };
|
||||
}
|
||||
|
||||
/**
|
||||
* A mock agent that returns a canned text and meta for each role.
|
||||
*/
|
||||
function createMockAgent(
|
||||
responses: Record<string, { text: string; meta: Record<string, unknown> }>,
|
||||
): JsonCasAgentFn {
|
||||
return async (role, _systemPrompt, _snapshot) => {
|
||||
const resp = responses[role];
|
||||
if (resp === undefined) {
|
||||
throw new Error(`mock agent: no response configured for role "${role}"`);
|
||||
}
|
||||
return resp;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("executeJsonCasThread", () => {
|
||||
describe("thread lifecycle", () => {
|
||||
test("simple two-role workflow creates start, two steps, and end nodes", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "I will plan", meta: { plan: "phase-1" } },
|
||||
coder: { text: "I wrote code", meta: { code: "done" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "Build a widget",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD01"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(result.summary).toContain("END");
|
||||
expect(result.rootHash).toBeTruthy();
|
||||
|
||||
const endNode = store.get(result.rootHash);
|
||||
expect(endNode).not.toBeNull();
|
||||
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||
expect(endPayload.returnCode).toBe(0);
|
||||
expect(endPayload.start).toBeTruthy();
|
||||
expect(endPayload.lastStep).toBeTruthy();
|
||||
});
|
||||
|
||||
test("single-role workflow creates correct chain", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: "work done", meta: { result: "success" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "Do the thing",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD02"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
|
||||
const endNode = store.get(result.rootHash);
|
||||
expect(endNode).not.toBeNull();
|
||||
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||
|
||||
const lastStepNode = store.get(endPayload.lastStep);
|
||||
expect(lastStepNode).not.toBeNull();
|
||||
const lastStepPayload = lastStepNode!.payload as ThreadStepPayload;
|
||||
expect(lastStepPayload.role).toBe("worker");
|
||||
expect(lastStepPayload.previous).toBeNull();
|
||||
|
||||
const startNode = store.get(endPayload.start);
|
||||
expect(startNode).not.toBeNull();
|
||||
const startPayload = startNode!.payload as ThreadStartPayload;
|
||||
expect(startPayload.input).toBe("Do the thing");
|
||||
expect(startPayload.depth).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CAS node structure", () => {
|
||||
test("thread-start contains workflow ref, input, depth, agents", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: "ok", meta: { result: "ok" } },
|
||||
});
|
||||
|
||||
const agentHash = await store.put(typeHashes.agent, {
|
||||
package: "test-agent",
|
||||
version: "1.0.0",
|
||||
config: {},
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "Test input",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD03"),
|
||||
options: makeOptions({ agents: { worker: agentHash }, depth: 2 }),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
|
||||
|
||||
expect(startPayload.workflow).toBe(workflowHash);
|
||||
expect(startPayload.input).toBe("Test input");
|
||||
expect(startPayload.depth).toBe(2);
|
||||
expect(startPayload.parentThread).toBeNull();
|
||||
expect(startPayload.agents).toEqual({ worker: agentHash });
|
||||
});
|
||||
|
||||
test("thread-start records parentThread when provided", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: "nested", meta: { result: "nested" } },
|
||||
});
|
||||
|
||||
const fakeParent = "FAKEPARENT0001";
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "nested task",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD04"),
|
||||
options: makeOptions({ parentThread: fakeParent, depth: 1 }),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
|
||||
expect(startPayload.parentThread).toBe(fakeParent);
|
||||
expect(startPayload.depth).toBe(1);
|
||||
});
|
||||
|
||||
test("each thread-step has content, react, start, and previous refs", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan text", meta: { plan: "p1" } },
|
||||
coder: { text: "code text", meta: { code: "c1" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "go",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD05"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const startHash = endPayload.start;
|
||||
|
||||
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
expect(step2.role).toBe("coder");
|
||||
expect(step2.start).toBe(startHash);
|
||||
expect(step2.previous).not.toBeNull();
|
||||
|
||||
const contentNode2 = store.get(step2.content);
|
||||
expect(contentNode2).not.toBeNull();
|
||||
expect((contentNode2!.payload as ContentPayload).text).toBe("code text");
|
||||
|
||||
const reactNode2 = store.get(step2.react);
|
||||
expect(reactNode2).not.toBeNull();
|
||||
|
||||
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||
expect(step1.role).toBe("planner");
|
||||
expect(step1.start).toBe(startHash);
|
||||
expect(step1.previous).toBeNull();
|
||||
|
||||
const contentNode1 = store.get(step1.content);
|
||||
expect(contentNode1).not.toBeNull();
|
||||
expect((contentNode1!.payload as ContentPayload).text).toBe("plan text");
|
||||
});
|
||||
|
||||
test("thread-end references start and last step", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan", meta: { plan: "x" } },
|
||||
coder: { text: "code", meta: { code: "x" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "test",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD06"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
expect(endPayload.returnCode).toBe(0);
|
||||
expect(endPayload.summary).toBeTruthy();
|
||||
|
||||
const startNode = store.get(endPayload.start);
|
||||
expect(startNode).not.toBeNull();
|
||||
expect((startNode!.payload as ThreadStartPayload).workflow).toBe(workflowHash);
|
||||
|
||||
const lastStepNode = store.get(endPayload.lastStep);
|
||||
expect(lastStepNode).not.toBeNull();
|
||||
expect((lastStepNode!.payload as ThreadStepPayload).role).toBe("coder");
|
||||
});
|
||||
|
||||
test("content nodes store the agent text verbatim", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const longText = "This is a longer text with\nnewlines\nand special chars: <>&\"'";
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: longText, meta: { result: "done" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "process this",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD07"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
const contentPayload = store.get(stepPayload.content)!.payload as ContentPayload;
|
||||
expect(contentPayload.text).toBe(longText);
|
||||
});
|
||||
|
||||
test("meta is stored in thread-step payload", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const complexMeta = {
|
||||
plan: "phase-1",
|
||||
phases: [{ hash: "abc", title: "first" }],
|
||||
nested: { deep: true },
|
||||
};
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan", meta: complexMeta },
|
||||
coder: { text: "code", meta: { code: "done" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "go",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD08"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||
|
||||
expect(step1.meta).toEqual(complexMeta);
|
||||
expect(step2.meta).toEqual({ code: "done" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("moderator routing", () => {
|
||||
test("conditional moderator routes based on agent meta", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
|
||||
|
||||
let checkerCallCount = 0;
|
||||
const agentFn: JsonCasAgentFn = async (role, _sp, _snap) => {
|
||||
if (role === "checker") {
|
||||
checkerCallCount++;
|
||||
if (checkerCallCount === 1) {
|
||||
return { text: "found issue", meta: { status: "bad" } };
|
||||
}
|
||||
return { text: "all good now", meta: { status: "ok" } };
|
||||
}
|
||||
return { text: "fixed it", meta: { fix: "patched" } };
|
||||
};
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "check and fix",
|
||||
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD09"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(checkerCallCount).toBe(2);
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const lastStep = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
expect(lastStep.role).toBe("checker");
|
||||
|
||||
const step2 = store.get(lastStep.previous!)!.payload as ThreadStepPayload;
|
||||
expect(step2.role).toBe("fixer");
|
||||
|
||||
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||
expect(step1.role).toBe("checker");
|
||||
expect(step1.previous).toBeNull();
|
||||
});
|
||||
|
||||
test("immediate END from moderator still produces a valid thread", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
|
||||
const immediateEnd: WorkflowInput = {
|
||||
name: "test-immediate-end",
|
||||
description: "Ends immediately",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Never called",
|
||||
systemPrompt: "N/A",
|
||||
extractPrompt: "N/A",
|
||||
schema: { type: "object" },
|
||||
},
|
||||
},
|
||||
moderator: [{ from: START, to: END, when: null }],
|
||||
};
|
||||
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, immediateEnd);
|
||||
|
||||
const agentFn: JsonCasAgentFn = async () => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "skip",
|
||||
moderatorRules: immediateEnd.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD10"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
|
||||
const endNode = store.get(result.rootHash);
|
||||
expect(endNode).not.toBeNull();
|
||||
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||
expect(endPayload.start).toBeTruthy();
|
||||
expect(endPayload.lastStep).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("abort handling", () => {
|
||||
test("aborted signal produces returnCode 130", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
|
||||
const agentFn: JsonCasAgentFn = async () => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "will abort",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD11"),
|
||||
options: makeOptions({ signal: ac.signal }),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(130);
|
||||
expect(result.summary).toContain("abort");
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
expect(endPayload.returnCode).toBe(130);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent receives correct context", () => {
|
||||
test("agent receives role name, system prompt, and accumulated steps", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const { loadWorkflow } = await import("@uncaged/workflow-json-def");
|
||||
const hydrated = loadWorkflow(store, typeHashes, workflowHash);
|
||||
|
||||
const receivedCalls: Array<{
|
||||
role: string;
|
||||
systemPrompt: string;
|
||||
stepCount: number;
|
||||
input: string;
|
||||
}> = [];
|
||||
|
||||
const agentFn: JsonCasAgentFn = async (role, systemPrompt, snapshot) => {
|
||||
receivedCalls.push({
|
||||
role,
|
||||
systemPrompt,
|
||||
stepCount: snapshot.steps.length,
|
||||
input: snapshot.start.input,
|
||||
});
|
||||
return { text: `output for ${role}`, meta: {} };
|
||||
};
|
||||
|
||||
await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "my prompt",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD12"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: hydrated,
|
||||
});
|
||||
|
||||
expect(receivedCalls.length).toBe(2);
|
||||
|
||||
expect(receivedCalls[0]!.role).toBe("planner");
|
||||
expect(receivedCalls[0]!.systemPrompt).toBe("You are a planner.");
|
||||
expect(receivedCalls[0]!.stepCount).toBe(0);
|
||||
expect(receivedCalls[0]!.input).toBe("my prompt");
|
||||
|
||||
expect(receivedCalls[1]!.role).toBe("coder");
|
||||
expect(receivedCalls[1]!.systemPrompt).toBe("You are a coder.");
|
||||
expect(receivedCalls[1]!.stepCount).toBe(1);
|
||||
});
|
||||
|
||||
test("snapshot accumulates step meta from previous rounds", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
|
||||
|
||||
let round = 0;
|
||||
const snapshots: Array<{ role: string; steps: readonly { role: string; meta: Record<string, unknown> }[] }> = [];
|
||||
|
||||
const agentFn: JsonCasAgentFn = async (role, _sp, snapshot) => {
|
||||
snapshots.push({ role, steps: [...snapshot.steps] });
|
||||
round++;
|
||||
if (role === "checker") {
|
||||
return round === 1
|
||||
? { text: "bad", meta: { status: "bad" } }
|
||||
: { text: "ok", meta: { status: "ok" } };
|
||||
}
|
||||
return { text: "fixed", meta: { fix: "yes" } };
|
||||
};
|
||||
|
||||
await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "go",
|
||||
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD13"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(snapshots.length).toBe(3);
|
||||
|
||||
expect(snapshots[0]!.steps.length).toBe(0);
|
||||
|
||||
expect(snapshots[1]!.steps.length).toBe(1);
|
||||
expect(snapshots[1]!.steps[0]!.role).toBe("checker");
|
||||
expect(snapshots[1]!.steps[0]!.meta).toEqual({ status: "bad" });
|
||||
|
||||
expect(snapshots[2]!.steps.length).toBe(2);
|
||||
expect(snapshots[2]!.steps[0]!.role).toBe("checker");
|
||||
expect(snapshots[2]!.steps[1]!.role).toBe("fixer");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJsonCasThreadSnapshot", () => {
|
||||
test("builds snapshot from start + step chain", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan text", meta: { plan: "alpha" } },
|
||||
coder: { text: "code text", meta: { code: "beta" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "build it",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD_SNAP"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const startHash = endPayload.start;
|
||||
const lastStepHash = endPayload.lastStep;
|
||||
|
||||
const snapshot = buildJsonCasThreadSnapshot(
|
||||
store, typeHashes, startHash, lastStepHash, "THREAD_SNAP",
|
||||
);
|
||||
|
||||
expect(snapshot.threadId).toBe("THREAD_SNAP");
|
||||
expect(snapshot.start.input).toBe("build it");
|
||||
expect(snapshot.start.workflowHash).toBe(workflowHash);
|
||||
expect(snapshot.steps.length).toBe(2);
|
||||
expect(snapshot.steps[0]!.role).toBe("planner");
|
||||
expect(snapshot.steps[0]!.meta).toEqual({ plan: "alpha" });
|
||||
expect(snapshot.steps[1]!.role).toBe("coder");
|
||||
expect(snapshot.steps[1]!.meta).toEqual({ code: "beta" });
|
||||
});
|
||||
|
||||
test("builds snapshot with null headStepHash (start only)", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: workflowHash,
|
||||
input: "just started",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: {},
|
||||
});
|
||||
|
||||
const snapshot = buildJsonCasThreadSnapshot(
|
||||
store, typeHashes, startHash, null, "THREAD_SNAP2",
|
||||
);
|
||||
|
||||
expect(snapshot.threadId).toBe("THREAD_SNAP2");
|
||||
expect(snapshot.start.input).toBe("just started");
|
||||
expect(snapshot.steps.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJsonCasThreadContext", () => {
|
||||
test("builds a protocol-compatible ThreadContext", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan text", meta: { plan: "ctx-test" } },
|
||||
coder: { text: "code text", meta: { code: "ctx-done" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "context test",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD_CTX"),
|
||||
options: makeOptions({ depth: 3 }),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const ctx = buildJsonCasThreadContext(
|
||||
store, typeHashes, endPayload.start, endPayload.lastStep,
|
||||
);
|
||||
|
||||
expect(ctx.threadId).toBe("");
|
||||
expect(ctx.depth).toBe(3);
|
||||
expect(ctx.bundleHash).toBe(workflowHash);
|
||||
expect(ctx.start.role).toBe("__start__");
|
||||
expect(ctx.start.content).toBe("context test");
|
||||
expect(ctx.steps.length).toBe(2);
|
||||
expect(ctx.steps[0]!.role).toBe("planner");
|
||||
expect(ctx.steps[0]!.meta).toEqual({ plan: "ctx-test" });
|
||||
expect(ctx.steps[1]!.role).toBe("coder");
|
||||
expect(ctx.steps[1]!.meta).toEqual({ code: "ctx-done" });
|
||||
});
|
||||
|
||||
test("context from start-only thread has empty steps", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: workflowHash,
|
||||
input: "start only",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: {},
|
||||
});
|
||||
|
||||
const ctx = buildJsonCasThreadContext(store, typeHashes, startHash, null);
|
||||
|
||||
expect(ctx.start.content).toBe("start only");
|
||||
expect(ctx.steps.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readContentText", () => {
|
||||
test("reads text from a content node", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const hash = await store.put(typeHashes.content, { text: "hello world" });
|
||||
|
||||
const text = readContentText(store, hash);
|
||||
expect(text).toBe("hello world");
|
||||
});
|
||||
|
||||
test("returns null for missing hash", async () => {
|
||||
const { store } = await setupStore();
|
||||
const text = readContentText(store, "NONEXISTENT0001");
|
||||
expect(text).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CAS graph integrity", () => {
|
||||
test("all nodes are reachable via walk from thread-end", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan", meta: { plan: "x" } },
|
||||
coder: { text: "code", meta: { code: "y" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "walk test",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD_WALK"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, result.rootHash, (hash) => {
|
||||
visited.add(hash);
|
||||
});
|
||||
|
||||
expect(visited.has(result.rootHash)).toBe(true);
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
expect(visited.has(endPayload.start)).toBe(true);
|
||||
expect(visited.has(endPayload.lastStep)).toBe(true);
|
||||
|
||||
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
expect(visited.has(step2.content)).toBe(true);
|
||||
expect(visited.has(step2.react)).toBe(true);
|
||||
expect(visited.has(step2.start)).toBe(true);
|
||||
|
||||
if (step2.previous !== null) {
|
||||
expect(visited.has(step2.previous)).toBe(true);
|
||||
const step1 = store.get(step2.previous)!.payload as ThreadStepPayload;
|
||||
expect(visited.has(step1.content)).toBe(true);
|
||||
expect(visited.has(step1.react)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("react session nodes have placeholder structure", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: "w", meta: { result: "r" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "react check",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD_REACT"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
const reactNode = store.get(stepPayload.react);
|
||||
|
||||
expect(reactNode).not.toBeNull();
|
||||
const reactPayload = reactNode!.payload as Record<string, unknown>;
|
||||
expect(reactPayload.turns).toEqual([]);
|
||||
expect(reactPayload.totalTokens).toBe(0);
|
||||
expect(reactPayload.durationMs).toBe(0);
|
||||
expect(reactPayload.role).toBe("worker");
|
||||
expect(typeof reactPayload.agent).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,9 @@
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/json-cas": "file:../../../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../../../json-cas/packages/json-cas-workflow",
|
||||
"@uncaged/workflow-json-def": "workspace:^",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -7,6 +7,18 @@ export {
|
||||
walkStateFramesNewestFirst,
|
||||
} from "./fork-thread.js";
|
||||
export { garbageCollectCas } from "./gc.js";
|
||||
export { buildJsonCasThreadContext, buildJsonCasThreadSnapshot, readContentText } from "./json-cas-context.js";
|
||||
export { executeJsonCasThread } from "./json-cas-engine.js";
|
||||
export type {
|
||||
AgentBindings,
|
||||
JsonCasAgentFn,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
JsonCasStartSnapshot,
|
||||
JsonCasStepSnapshot,
|
||||
JsonCasThreadPauseGate,
|
||||
JsonCasThreadSnapshot,
|
||||
} from "./json-cas-types.js";
|
||||
export { createThreadPauseGate } from "./thread-pause-gate.js";
|
||||
export type { ThreadHistoryEntry, ThreadIndex, ThreadIndexEntry } from "./threads-index.js";
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ThreadStartPayload,
|
||||
ThreadStepPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import type { ThreadContext } from "@uncaged/workflow-protocol";
|
||||
import { START } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { JsonCasStepSnapshot, JsonCasThreadSnapshot } from "./json-cas-types.js";
|
||||
|
||||
// ── Snapshot builder (lightweight, for agent & moderator) ─────────────
|
||||
|
||||
/**
|
||||
* Walk the thread-step chain backwards via `previous` refs, then reverse
|
||||
* to get chronological order. Returns a {@link JsonCasThreadSnapshot}.
|
||||
*/
|
||||
export function buildJsonCasThreadSnapshot(
|
||||
store: Store,
|
||||
_typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
headStepHash: Hash | null,
|
||||
threadId: string,
|
||||
): JsonCasThreadSnapshot {
|
||||
const startNode = store.get(startHash);
|
||||
if (startNode === null) {
|
||||
throw new Error(`buildJsonCasThreadSnapshot: missing thread-start node at ${startHash}`);
|
||||
}
|
||||
const startPayload = startNode.payload as ThreadStartPayload;
|
||||
|
||||
const steps: JsonCasStepSnapshot[] = [];
|
||||
|
||||
let cursor: Hash | null = headStepHash;
|
||||
while (cursor !== null) {
|
||||
const stepNode = store.get(cursor);
|
||||
if (stepNode === null) {
|
||||
throw new Error(`buildJsonCasThreadSnapshot: missing thread-step node at ${cursor}`);
|
||||
}
|
||||
const stepPayload = stepNode.payload as ThreadStepPayload;
|
||||
steps.push({
|
||||
role: stepPayload.role,
|
||||
meta: stepPayload.meta,
|
||||
contentHash: stepPayload.content,
|
||||
});
|
||||
cursor = stepPayload.previous;
|
||||
}
|
||||
|
||||
steps.reverse();
|
||||
|
||||
return {
|
||||
threadId,
|
||||
start: {
|
||||
input: startPayload.input,
|
||||
depth: startPayload.depth,
|
||||
workflowHash: startPayload.workflow,
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ThreadContext builder (protocol-compatible) ───────────────────────
|
||||
|
||||
/**
|
||||
* Build a full {@link ThreadContext} from a json-cas thread chain.
|
||||
* Reads the thread-start node, walks thread-step backwards, and resolves
|
||||
* content text from each step's content node.
|
||||
*
|
||||
* `bundleHash` is set from the workflow ref in the thread-start payload.
|
||||
* `threadId` is set to `""` — callers should overwrite when known.
|
||||
*/
|
||||
export function buildJsonCasThreadContext(
|
||||
store: Store,
|
||||
_typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
headStepHash: Hash | null,
|
||||
): ThreadContext {
|
||||
const startNode = store.get(startHash);
|
||||
if (startNode === null) {
|
||||
throw new Error(`buildJsonCasThreadContext: missing thread-start node at ${startHash}`);
|
||||
}
|
||||
const startPayload = startNode.payload as ThreadStartPayload;
|
||||
|
||||
const rawSteps: ThreadStepPayload[] = [];
|
||||
let cursor: Hash | null = headStepHash;
|
||||
while (cursor !== null) {
|
||||
const stepNode = store.get(cursor);
|
||||
if (stepNode === null) {
|
||||
throw new Error(`buildJsonCasThreadContext: missing thread-step node at ${cursor}`);
|
||||
}
|
||||
const payload = stepNode.payload as ThreadStepPayload;
|
||||
rawSteps.push(payload);
|
||||
cursor = payload.previous;
|
||||
}
|
||||
rawSteps.reverse();
|
||||
|
||||
const steps = rawSteps.map((sp) => ({
|
||||
role: sp.role,
|
||||
meta: sp.meta,
|
||||
contentHash: sp.content,
|
||||
refs: [] as string[],
|
||||
timestamp: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
threadId: "",
|
||||
depth: startPayload.depth,
|
||||
bundleHash: startPayload.workflow,
|
||||
start: {
|
||||
role: START,
|
||||
content: startPayload.input,
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: startPayload.parentThread,
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the text payload from a content node.
|
||||
*/
|
||||
export function readContentText(store: Store, contentHash: Hash): string | null {
|
||||
const node = store.get(contentHash);
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
const payload = node.payload as ContentPayload;
|
||||
return payload.text;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ThreadEndPayload,
|
||||
ThreadStartPayload,
|
||||
ThreadStepPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
|
||||
import type { ModeratorRule, WorkflowResult } from "@uncaged/workflow-protocol";
|
||||
import { END, evaluateModerator, START } from "@uncaged/workflow-protocol";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
|
||||
import type {
|
||||
AgentBindings,
|
||||
JsonCasAgentFn,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
JsonCasStepSnapshot,
|
||||
JsonCasThreadSnapshot,
|
||||
} from "./json-cas-types.js";
|
||||
|
||||
// ── Helpers: CAS node writers ─────────────────────────────────────────
|
||||
|
||||
async function writeContent(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
text: string,
|
||||
): Promise<Hash> {
|
||||
const payload: ContentPayload = { text };
|
||||
return store.put(typeHashes.content, payload);
|
||||
}
|
||||
|
||||
async function writePlaceholderReactSession(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
role: string,
|
||||
agentHash: Hash,
|
||||
): Promise<Hash> {
|
||||
return store.put(typeHashes.reactSession, {
|
||||
agent: agentHash,
|
||||
role,
|
||||
turns: [],
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeThreadStart(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
params: {
|
||||
workflowHash: Hash;
|
||||
input: string;
|
||||
depth: number;
|
||||
parentThread: Hash | null;
|
||||
agents: AgentBindings;
|
||||
},
|
||||
): Promise<Hash> {
|
||||
const payload: ThreadStartPayload = {
|
||||
workflow: params.workflowHash,
|
||||
input: params.input,
|
||||
depth: params.depth,
|
||||
parentThread: params.parentThread,
|
||||
agents: params.agents,
|
||||
};
|
||||
return store.put(typeHashes.threadStart, payload);
|
||||
}
|
||||
|
||||
async function writeThreadStep(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
params: {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
contentHash: Hash;
|
||||
reactHash: Hash;
|
||||
startHash: Hash;
|
||||
previousHash: Hash | null;
|
||||
},
|
||||
): Promise<Hash> {
|
||||
const payload: ThreadStepPayload = {
|
||||
role: params.role,
|
||||
meta: params.meta,
|
||||
content: params.contentHash,
|
||||
react: params.reactHash,
|
||||
start: params.startHash,
|
||||
previous: params.previousHash,
|
||||
};
|
||||
return store.put(typeHashes.threadStep, payload);
|
||||
}
|
||||
|
||||
async function writeThreadEnd(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
params: {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
startHash: Hash;
|
||||
lastStepHash: Hash;
|
||||
},
|
||||
): Promise<Hash> {
|
||||
const payload: ThreadEndPayload = {
|
||||
returnCode: params.returnCode,
|
||||
summary: params.summary,
|
||||
start: params.startHash,
|
||||
lastStep: params.lastStepHash,
|
||||
};
|
||||
return store.put(typeHashes.threadEnd, payload);
|
||||
}
|
||||
|
||||
// ── Placeholder agent ─────────────────────────────────────────────────
|
||||
|
||||
async function ensurePlaceholderAgent(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
): Promise<Hash> {
|
||||
return store.put(typeHashes.agent, {
|
||||
package: "placeholder",
|
||||
version: "0.0.0",
|
||||
config: {},
|
||||
});
|
||||
}
|
||||
|
||||
// ── JSONata moderator adapter ─────────────────────────────────────────
|
||||
|
||||
function snapshotToModeratorContext(
|
||||
snapshot: JsonCasThreadSnapshot,
|
||||
): Parameters<typeof evaluateModerator>[1] {
|
||||
return {
|
||||
threadId: snapshot.threadId,
|
||||
depth: snapshot.start.depth,
|
||||
bundleHash: snapshot.start.workflowHash,
|
||||
start: {
|
||||
role: START,
|
||||
content: snapshot.start.input,
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
},
|
||||
steps: snapshot.steps.map((s) => ({
|
||||
role: s.role,
|
||||
meta: s.meta,
|
||||
contentHash: s.contentHash,
|
||||
refs: [],
|
||||
timestamp: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main engine ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a workflow thread using json-cas as the storage layer.
|
||||
*
|
||||
* Drives the moderator→agent loop:
|
||||
* 1. Writes a thread-start node.
|
||||
* 2. On each round: evaluates the moderator, invokes the agent, writes
|
||||
* content + thread-step nodes (react is a placeholder for now).
|
||||
* 3. On END: writes a thread-end node and returns the result.
|
||||
*
|
||||
* The `agentFn` callback is invoked for each role step. It receives the
|
||||
* role name, system prompt, and current thread snapshot, and returns the
|
||||
* agent's text output plus structured meta.
|
||||
*/
|
||||
export async function executeJsonCasThread(params: {
|
||||
workflowHash: Hash;
|
||||
input: string;
|
||||
moderatorRules: readonly ModeratorRule[];
|
||||
io: JsonCasEngineIo;
|
||||
options: JsonCasEngineOptions;
|
||||
agentFn: JsonCasAgentFn;
|
||||
logger: LogFn;
|
||||
/** Hydrated workflow for role system prompts. Null disables prompt forwarding. */
|
||||
workflow: HydratedWorkflow | null;
|
||||
}): Promise<WorkflowResult> {
|
||||
const { io, options, agentFn, logger, moderatorRules, workflow } = params;
|
||||
const { store, typeHashes, threadId } = io;
|
||||
|
||||
const placeholderAgentHash = await ensurePlaceholderAgent(store, typeHashes);
|
||||
|
||||
const startHash = await writeThreadStart(store, typeHashes, {
|
||||
workflowHash: params.workflowHash,
|
||||
input: params.input,
|
||||
depth: options.depth,
|
||||
parentThread: options.parentThread,
|
||||
agents: options.agents,
|
||||
});
|
||||
|
||||
logger("X3RK7QWN", `json-cas thread ${threadId} started`);
|
||||
|
||||
let previousStepHash: Hash | null = null;
|
||||
let headStepHash: Hash | null = null;
|
||||
const stepSnapshots: JsonCasStepSnapshot[] = [];
|
||||
|
||||
while (true) {
|
||||
if (options.signal.aborted) {
|
||||
return abortThread(store, typeHashes, startHash, headStepHash, logger, threadId);
|
||||
}
|
||||
|
||||
const snapshot: JsonCasThreadSnapshot = {
|
||||
threadId,
|
||||
start: {
|
||||
input: params.input,
|
||||
depth: options.depth,
|
||||
workflowHash: params.workflowHash,
|
||||
},
|
||||
steps: stepSnapshots,
|
||||
};
|
||||
|
||||
const modCtx = snapshotToModeratorContext(snapshot);
|
||||
const nextRole = await evaluateModerator(moderatorRules, modCtx);
|
||||
|
||||
if (nextRole === END) {
|
||||
logger("Y5TN8RVK", `json-cas thread ${threadId} moderator returned END`);
|
||||
|
||||
if (headStepHash === null) {
|
||||
const dummyContentHash = await writeContent(store, typeHashes, "no-op");
|
||||
const dummyReactHash = await writePlaceholderReactSession(
|
||||
store,
|
||||
typeHashes,
|
||||
END,
|
||||
placeholderAgentHash,
|
||||
);
|
||||
headStepHash = await writeThreadStep(store, typeHashes, {
|
||||
role: END,
|
||||
meta: {},
|
||||
contentHash: dummyContentHash,
|
||||
reactHash: dummyReactHash,
|
||||
startHash,
|
||||
previousHash: null,
|
||||
});
|
||||
}
|
||||
|
||||
const endHash = await writeThreadEnd(store, typeHashes, {
|
||||
returnCode: 0,
|
||||
summary: "completed: moderator returned END",
|
||||
startHash,
|
||||
lastStepHash: headStepHash,
|
||||
});
|
||||
|
||||
return { returnCode: 0, summary: "completed: moderator returned END", rootHash: endHash };
|
||||
}
|
||||
|
||||
const roleSystemPrompt =
|
||||
workflow !== null && workflow.roles[nextRole] !== undefined
|
||||
? workflow.roles[nextRole].systemPrompt
|
||||
: "";
|
||||
|
||||
const agentResult = await agentFn(nextRole, roleSystemPrompt, snapshot);
|
||||
|
||||
const contentHash = await writeContent(store, typeHashes, agentResult.text);
|
||||
|
||||
const agentHash = options.agents[nextRole] ?? placeholderAgentHash;
|
||||
const reactHash = await writePlaceholderReactSession(store, typeHashes, nextRole, agentHash);
|
||||
|
||||
const stepHash = await writeThreadStep(store, typeHashes, {
|
||||
role: nextRole,
|
||||
meta: agentResult.meta,
|
||||
contentHash,
|
||||
reactHash,
|
||||
startHash,
|
||||
previousHash: previousStepHash,
|
||||
});
|
||||
|
||||
previousStepHash = stepHash;
|
||||
headStepHash = stepHash;
|
||||
stepSnapshots.push({
|
||||
role: nextRole,
|
||||
meta: agentResult.meta,
|
||||
contentHash,
|
||||
});
|
||||
|
||||
logger("Z7WP4NHK", `json-cas thread ${threadId} wrote role ${nextRole}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function abortThread(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
headStepHash: Hash | null,
|
||||
logger: LogFn,
|
||||
threadId: string,
|
||||
): Promise<WorkflowResult> {
|
||||
logger("A8QK3VNR", `json-cas thread ${threadId} aborted`);
|
||||
|
||||
const placeholderAgentHash = await ensurePlaceholderAgent(store, typeHashes);
|
||||
|
||||
let lastStep = headStepHash;
|
||||
if (lastStep === null) {
|
||||
const dummyContentHash = await writeContent(store, typeHashes, "thread aborted");
|
||||
const dummyReactHash = await writePlaceholderReactSession(
|
||||
store,
|
||||
typeHashes,
|
||||
END,
|
||||
placeholderAgentHash,
|
||||
);
|
||||
lastStep = await writeThreadStep(store, typeHashes, {
|
||||
role: END,
|
||||
meta: {},
|
||||
contentHash: dummyContentHash,
|
||||
reactHash: dummyReactHash,
|
||||
startHash,
|
||||
previousHash: null,
|
||||
});
|
||||
}
|
||||
|
||||
const endHash = await writeThreadEnd(store, typeHashes, {
|
||||
returnCode: 130,
|
||||
summary: "thread aborted",
|
||||
startHash,
|
||||
lastStepHash: lastStep,
|
||||
});
|
||||
|
||||
return { returnCode: 130, summary: "thread aborted", rootHash: endHash };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
|
||||
|
||||
import type { Result } from "@uncaged/workflow-util";
|
||||
|
||||
// ── Engine IO ─────────────────────────────────────────────────────────
|
||||
|
||||
export type JsonCasEngineIo = {
|
||||
threadId: string;
|
||||
store: Store;
|
||||
typeHashes: WorkflowSchemaHashes;
|
||||
};
|
||||
|
||||
// ── Agent binding ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps each role name to a CAS hash referencing an agent node.
|
||||
* Phase 4 uses a simple role→hash mapping; full agent resolution comes later.
|
||||
*/
|
||||
export type AgentBindings = Record<string, Hash>;
|
||||
|
||||
// ── Engine options ────────────────────────────────────────────────────
|
||||
|
||||
export type JsonCasEngineOptions = {
|
||||
depth: number;
|
||||
parentThread: Hash | null;
|
||||
signal: AbortSignal;
|
||||
agents: AgentBindings;
|
||||
};
|
||||
|
||||
// ── Agent function (mock-friendly) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invoked for each role step. Returns the agent's raw text output and
|
||||
* structured meta. The engine stores the text in a content node and the
|
||||
* meta inside the thread-step node.
|
||||
*/
|
||||
export type JsonCasAgentFn = (
|
||||
role: string,
|
||||
systemPrompt: string,
|
||||
context: JsonCasThreadSnapshot,
|
||||
) => Promise<{ text: string; meta: Record<string, unknown> }>;
|
||||
|
||||
// ── Thread snapshot (read-only view for agents & moderator) ───────────
|
||||
|
||||
export type JsonCasStartSnapshot = {
|
||||
input: string;
|
||||
depth: number;
|
||||
workflowHash: Hash;
|
||||
};
|
||||
|
||||
export type JsonCasStepSnapshot = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
contentHash: Hash;
|
||||
};
|
||||
|
||||
export type JsonCasThreadSnapshot = {
|
||||
threadId: string;
|
||||
start: JsonCasStartSnapshot;
|
||||
steps: readonly JsonCasStepSnapshot[];
|
||||
};
|
||||
|
||||
// ── Thread pause gate (re-use from existing types) ────────────────────
|
||||
|
||||
export type JsonCasThreadPauseGate = {
|
||||
awaitAfterYield: () => Promise<void>;
|
||||
pause: () => Result<void, string>;
|
||||
resume: () => Result<void, string>;
|
||||
isPaused: () => boolean;
|
||||
};
|
||||
@@ -3,9 +3,7 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
importWorkflowBundleModule,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { importWorkflowBundleModule } from "@uncaged/workflow-register";
|
||||
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
|
||||
@@ -4,6 +4,18 @@ export {
|
||||
walkStateFramesNewestFirst,
|
||||
} from "./engine/fork-thread.js";
|
||||
export { garbageCollectCas } from "./engine/gc.js";
|
||||
export { buildJsonCasThreadContext, buildJsonCasThreadSnapshot, readContentText } from "./engine/json-cas-context.js";
|
||||
export { executeJsonCasThread } from "./engine/json-cas-engine.js";
|
||||
export type {
|
||||
AgentBindings,
|
||||
JsonCasAgentFn,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
JsonCasStartSnapshot,
|
||||
JsonCasStepSnapshot,
|
||||
JsonCasThreadPauseGate,
|
||||
JsonCasThreadSnapshot,
|
||||
} from "./engine/json-cas-types.js";
|
||||
export type {
|
||||
ThreadHistoryEntry,
|
||||
ThreadIndex,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-reactor" },
|
||||
{ "path": "../workflow-register" }
|
||||
{ "path": "../workflow-register" },
|
||||
{ "path": "../workflow-json-def" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CasNode } from "@uncaged/json-cas";
|
||||
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
|
||||
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
|
||||
import {
|
||||
developWorkflow,
|
||||
END,
|
||||
loadWorkflow,
|
||||
registerWorkflow,
|
||||
START,
|
||||
solveIssueWorkflow,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bootstrap — registerWorkflowSchemas returns all 11 schema hashes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Step 1: registerWorkflowSchemas", () => {
|
||||
test("returns 11 distinct 13-char Crockford Base32 hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const values = Object.values(hashes);
|
||||
expect(values).toHaveLength(11);
|
||||
for (const h of values) {
|
||||
expect(h).toHaveLength(13);
|
||||
expect(h).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
}
|
||||
expect(new Set(values).size).toBe(11);
|
||||
});
|
||||
|
||||
test("is idempotent across multiple calls", async () => {
|
||||
const store = createMemoryStore();
|
||||
const first = await registerWorkflowSchemas(store);
|
||||
const second = await registerWorkflowSchemas(store);
|
||||
|
||||
for (const key of Object.keys(first) as (keyof typeof first)[]) {
|
||||
expect(first[key]).toBe(second[key]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 2: registerWorkflow — stores roles + workflow in CAS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Step 2: registerWorkflow", () => {
|
||||
test("returns a 13-char Crockford Base32 workflow hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("is idempotent: registering the same workflow twice returns the same hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash1 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const hash2 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("workflow node is present in the store after registration", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
|
||||
expect(store.get(hash)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("stores role nodes — one per role in the definition", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const wfNode = store.get(wfHash) as CasNode;
|
||||
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
|
||||
|
||||
expect(Object.keys(roles)).toHaveLength(Object.keys(solveIssueWorkflow.roles).length);
|
||||
for (const roleHash of Object.values(roles)) {
|
||||
expect(store.get(roleHash)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("stores role-schema nodes — one per role", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
|
||||
const wfNode = store.get(wfHash) as CasNode;
|
||||
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
|
||||
|
||||
for (const roleHash of Object.values(roles)) {
|
||||
const roleNode = store.get(roleHash) as CasNode;
|
||||
const schemaHash = (roleNode.payload as Record<string, string>).schema;
|
||||
expect(store.get(schemaHash)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("workflow payload contains correct name and description", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
|
||||
const node = store.get(hash) as CasNode;
|
||||
const payload = node.payload as Record<string, unknown>;
|
||||
|
||||
expect(payload.name).toBe("develop");
|
||||
expect(payload.description).toBe(developWorkflow.description);
|
||||
});
|
||||
|
||||
test("workflow payload contains moderator rules array", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const node = store.get(hash) as CasNode;
|
||||
const payload = node.payload as Record<string, unknown>;
|
||||
|
||||
expect(Array.isArray(payload.moderator)).toBe(true);
|
||||
const rules = payload.moderator as Array<{ from: string; to: string; when: string | null }>;
|
||||
expect(rules.some((r) => r.from === START)).toBe(true);
|
||||
expect(rules.some((r) => r.to === END)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 3: loadWorkflow — round-trip hydration from CAS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Step 3: loadWorkflow", () => {
|
||||
test("returns null for an unknown hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
expect(loadWorkflow(store, typeHashes, "AAAAAAAAAAAAA")).toBeNull();
|
||||
});
|
||||
|
||||
test("hydrates solve-issue workflow with correct name and description", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const result = loadWorkflow(store, typeHashes, hash);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe("solve-issue");
|
||||
expect(result?.description).toBe(solveIssueWorkflow.description);
|
||||
});
|
||||
|
||||
test("hydrated workflow contains all roles", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const result = loadWorkflow(store, typeHashes, hash);
|
||||
|
||||
const expectedRoles = Object.keys(solveIssueWorkflow.roles);
|
||||
expect(Object.keys(result?.roles ?? {})).toEqual(expect.arrayContaining(expectedRoles));
|
||||
});
|
||||
|
||||
test("hydrated role has correct systemPrompt and description", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const result = loadWorkflow(store, typeHashes, hash);
|
||||
|
||||
const preparer = result?.roles.preparer;
|
||||
expect(preparer?.description).toBe(solveIssueWorkflow.roles.preparer.description);
|
||||
expect(preparer?.systemPrompt).toBe(solveIssueWorkflow.roles.preparer.systemPrompt);
|
||||
expect(preparer?.extractPrompt).toBe(solveIssueWorkflow.roles.preparer.extractPrompt);
|
||||
});
|
||||
|
||||
test("hydrated role includes the JSON Schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const result = loadWorkflow(store, typeHashes, hash);
|
||||
|
||||
const schema = result?.roles.preparer?.schema;
|
||||
expect(schema).toBeDefined();
|
||||
expect((schema as Record<string, unknown>)?.type).toBe("object");
|
||||
});
|
||||
|
||||
test("hydrated workflow contains moderator rules matching the definition", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
|
||||
const result = loadWorkflow(store, typeHashes, hash);
|
||||
|
||||
expect(result?.moderator).toHaveLength(developWorkflow.moderator.length);
|
||||
expect(result?.moderator[0]).toEqual(developWorkflow.moderator[0]);
|
||||
});
|
||||
|
||||
test("develop workflow round-trip has 5 roles", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
|
||||
const result = loadWorkflow(store, typeHashes, hash);
|
||||
|
||||
expect(Object.keys(result?.roles ?? {})).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 4: validate() — CAS nodes pass validation against their schemas
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Step 4: validate", () => {
|
||||
test("workflow node is valid against its schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const node = store.get(hash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("role nodes are valid against their schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const wfNode = store.get(wfHash) as CasNode;
|
||||
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
|
||||
|
||||
for (const roleHash of Object.values(roles)) {
|
||||
const roleNode = store.get(roleHash) as CasNode;
|
||||
expect(validate(store, roleNode)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("role-schema nodes are valid against their schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
|
||||
const wfNode = store.get(wfHash) as CasNode;
|
||||
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
|
||||
|
||||
for (const roleHash of Object.values(roles)) {
|
||||
const roleNode = store.get(roleHash) as CasNode;
|
||||
const schemaHash = (roleNode.payload as Record<string, string>).schema;
|
||||
const schemaNode = store.get(schemaHash) as CasNode;
|
||||
expect(validate(store, schemaNode)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("workflow node with wrong type for roles fails validation", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const badHash = await store.put(typeHashes.workflow, {
|
||||
name: "bad",
|
||||
description: "bad",
|
||||
roles: "not-an-object",
|
||||
moderator: [],
|
||||
});
|
||||
const node = store.get(badHash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(false);
|
||||
});
|
||||
|
||||
test("role node missing required field fails validation", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const badHash = await store.put(typeHashes.role, {
|
||||
name: "bad",
|
||||
description: "d",
|
||||
systemPrompt: "s",
|
||||
});
|
||||
const node = store.get(badHash) as CasNode;
|
||||
|
||||
expect(validate(store, node)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 5: refs() — extracts cas_ref hashes from workflow and role nodes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Step 5: refs", () => {
|
||||
test("workflow node refs() returns one hash per role", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const node = store.get(hash) as CasNode;
|
||||
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
|
||||
|
||||
expect(refs(store, node)).toHaveLength(roleCount);
|
||||
});
|
||||
|
||||
test("role node refs() returns exactly one hash (the schema)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const wfNode = store.get(wfHash) as CasNode;
|
||||
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
|
||||
const firstRoleHash = Object.values(roles)[0];
|
||||
|
||||
const roleNode = store.get(firstRoleHash) as CasNode;
|
||||
const roleRefs = refs(store, roleNode);
|
||||
|
||||
expect(roleRefs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("role refs() points to the role-schema node", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const wfNode = store.get(wfHash) as CasNode;
|
||||
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
|
||||
const firstRoleHash = Object.values(roles)[0];
|
||||
const roleNode = store.get(firstRoleHash) as CasNode;
|
||||
|
||||
const schemaHash = refs(store, roleNode)[0];
|
||||
const schemaNode = store.get(schemaHash);
|
||||
|
||||
expect(schemaNode).not.toBeNull();
|
||||
expect(schemaNode?.type).toBe(typeHashes.roleSchema);
|
||||
});
|
||||
|
||||
test("develop workflow node refs() returns one hash per role (5 roles)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
|
||||
const node = store.get(hash) as CasNode;
|
||||
|
||||
expect(refs(store, node)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 6: walk() — BFS traversal visits workflow, role, and schema nodes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Step 6: walk", () => {
|
||||
test("walk from workflow hash visits workflow + role + schema nodes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, wfHash, (h) => visited.add(h));
|
||||
|
||||
// workflow node itself
|
||||
expect(visited.has(wfHash)).toBe(true);
|
||||
|
||||
// all role nodes and their schema nodes should be reachable
|
||||
const wfNode = store.get(wfHash) as CasNode;
|
||||
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
|
||||
for (const roleHash of Object.values(roles)) {
|
||||
expect(visited.has(roleHash)).toBe(true);
|
||||
const roleNode = store.get(roleHash) as CasNode;
|
||||
const schemaHash = refs(store, roleNode)[0];
|
||||
expect(visited.has(schemaHash)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("walk visits all 5 role nodes for develop workflow", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, wfHash, (h) => visited.add(h));
|
||||
|
||||
const wfNode = store.get(wfHash) as CasNode;
|
||||
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
|
||||
expect(Object.values(roles).every((rh) => visited.has(rh))).toBe(true);
|
||||
});
|
||||
|
||||
test("walk total node count = 1 workflow + N roles + N schemas", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, wfHash, (h) => visited.add(h));
|
||||
|
||||
// 1 workflow + roleCount roles + roleCount schemas
|
||||
expect(visited.size).toBe(1 + roleCount + roleCount);
|
||||
});
|
||||
|
||||
test("walk handles two workflows sharing a schema node — visits it only once", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
// Register the same workflow twice — second call is idempotent, same hashes
|
||||
const hash1 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
const hash2 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, hash1, (h) => visited.add(h));
|
||||
|
||||
// Each node should be counted exactly once despite any shared refs
|
||||
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
|
||||
expect(visited.size).toBe(1 + roleCount + roleCount);
|
||||
});
|
||||
|
||||
test("walk with unknown starting hash visits nothing", () => {
|
||||
const store = createMemoryStore();
|
||||
const visited: string[] = [];
|
||||
walk(store, "AAAAAAAAAAAAA", (h) => visited.push(h));
|
||||
|
||||
expect(visited).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-json-def",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "file:../../../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../../../json-cas/packages/json-cas-workflow"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const START = "__start__" as const;
|
||||
export const END = "__end__" as const;
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { WorkflowInput } from "../types.js";
|
||||
import { END, START } from "./constants.js";
|
||||
|
||||
export const DEVELOP_WORKFLOW_DESCRIPTION =
|
||||
"Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer).";
|
||||
|
||||
// ── JSONata conditions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when the planner aborted due to insufficient information.
|
||||
* Translates the plannerAborted TypeScript condition to JSONata.
|
||||
*/
|
||||
const PLANNER_ABORTED = "$boolean(steps[role='planner'].meta.status = 'aborted')";
|
||||
|
||||
/**
|
||||
* True when all planned phases have been completed by the coder.
|
||||
*
|
||||
* Logic:
|
||||
* - No planned phases → true (nothing to complete)
|
||||
* - Last phase hash appears in any coder step's completedPhase → true
|
||||
* - Every phase hash appears in some coder's completedPhase → true (via count check)
|
||||
*/
|
||||
const ALL_PHASES_COMPLETE = [
|
||||
"(",
|
||||
" $plannerMeta := steps[role='planner'].meta;",
|
||||
" $phases := $plannerMeta.status = 'planned' ? $plannerMeta.phases : [];",
|
||||
" $count($phases) = 0 ? true :",
|
||||
" (",
|
||||
" $lastHash := $phases[-1].hash;",
|
||||
" $completedHashes := steps[role='coder'].meta.completedPhase;",
|
||||
" $lastHash in $completedHashes or",
|
||||
" $count($phases[$not(hash in $completedHashes)]) = 0",
|
||||
" )",
|
||||
")",
|
||||
].join(" ");
|
||||
|
||||
/** True when the most recent reviewer step reported approved. */
|
||||
const REVIEW_APPROVED = "steps[-1].meta.status = 'approved'";
|
||||
|
||||
/** True when the most recent tester step reported passed. */
|
||||
const TESTS_PASSED = "steps[-1].meta.status = 'passed'";
|
||||
|
||||
// ── Workflow definition ───────────────────────────────────────────────────────
|
||||
|
||||
export const developWorkflow: WorkflowInput = {
|
||||
name: "develop",
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time. **Abort** if the prompt lacks critical information (e.g. no project/workspace path, ambiguous target repo).
|
||||
|
||||
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
|
||||
|
||||
## Prerequisites — check FIRST
|
||||
|
||||
The prompt MUST include an **absolute filesystem path** to the project workspace (e.g. \`/home/user/repos/my-project\`). If no workspace path is given and you cannot reliably infer one from context, **abort immediately** with a clear reason explaining what information is missing. Do NOT guess paths.
|
||||
|
||||
## Storing phase details — MANDATORY
|
||||
|
||||
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
|
||||
|
||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||
|
||||
**Do NOT store phase details in any other way** — the CLI is the only supported storage mechanism.
|
||||
|
||||
## Phase granularity
|
||||
|
||||
Match the number of phases to task complexity:
|
||||
- Trivial (add a config option, fix a typo, rename): 1 phase
|
||||
- Small (a new feature touching 2-3 files): 1-2 phases
|
||||
- Medium (cross-module refactor): 2-3 phases
|
||||
- Large (new subsystem, architectural change): 3-5 phases
|
||||
|
||||
Fewer phases is always better. Each phase must justify its existence — if two phases would be tested together anyway, merge them.
|
||||
|
||||
## Output format
|
||||
|
||||
After storing all phases via the CLI, output compact JSON only:
|
||||
{ "status": "planned", "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
|
||||
|
||||
If aborting:
|
||||
{ "status": "aborted", "reason": "<what is missing>" }
|
||||
|
||||
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short** — just the JSON with phases. Do NOT paste code snippets, diffs, or implementation details in your response. Phase details are already stored in CAS; your response should only contain the compact phases JSON.`,
|
||||
extractPrompt:
|
||||
"Extract the planner result as JSON. Use status='planned' with phases array (hash+title), or status='aborted' with reason.",
|
||||
schema: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
required: ["status", "phases"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["planned"] },
|
||||
phases: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["hash", "title"],
|
||||
properties: {
|
||||
hash: { type: "string" },
|
||||
title: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
required: ["status", "reason"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["aborted"] },
|
||||
reason: { type: "string" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
coder: {
|
||||
description:
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
||||
|
||||
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
|
||||
|
||||
## Reading phase details
|
||||
|
||||
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <HASH>\`.
|
||||
|
||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||
|
||||
## Completing a phase
|
||||
|
||||
Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short** — a brief summary paragraph plus the structured meta output. Do NOT paste diffs, file contents, or code blocks in your response. The actual changes are already on disk; repeating them wastes tokens. Just say what you did and why.`,
|
||||
extractPrompt:
|
||||
"Extract the coder result as JSON with fields: completedPhase (hash string), filesChanged (array), summary.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["completedPhase", "filesChanged", "summary"],
|
||||
properties: {
|
||||
completedPhase: { type: "string" },
|
||||
filesChanged: { type: "array", items: { type: "string" } },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
reviewer: {
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
systemPrompt: `You are a code reviewer. Review the git diff for correctness, consistency, and adherence to project conventions.
|
||||
|
||||
## Review process
|
||||
|
||||
1. Read the **preparer**'s output in the thread for project conventions (coding style, naming, commit format, etc.).
|
||||
2. Review the diff against these conventions.
|
||||
3. For documentation changes, verify that names, paths, and references match the actual codebase.
|
||||
|
||||
## Review checklist
|
||||
|
||||
- **Correctness** — does the code do what it claims? Logic bugs, off-by-one, missing returns?
|
||||
- **Conventions** — naming, imports, code style per project rules?
|
||||
- **Consistency** — do docs/comments match actual code? Are references current and accurate?
|
||||
- **Edge cases** — missing error handling, null checks, boundary conditions?
|
||||
|
||||
## Verdict
|
||||
|
||||
- **Approve** only if there are zero issues
|
||||
- **Reject** with specific issues that must be fixed — every issue you find is blocking
|
||||
|
||||
Be thorough. A false approve costs more than a false reject.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short**. Summarize findings in a few bullet points, then output the structured verdict. Do NOT paste the full diff or large code blocks in your response.`,
|
||||
extractPrompt:
|
||||
"Extract the reviewer verdict as JSON. Use status='approved', or status='rejected' with issues array.",
|
||||
schema: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
required: ["status"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["approved"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
required: ["status", "issues"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["rejected"] },
|
||||
issues: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
tester: {
|
||||
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||
systemPrompt: `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short**. Report pass/fail with a brief summary of failures (if any). Do NOT paste full test output or build logs — just the key error lines.`,
|
||||
extractPrompt:
|
||||
"Extract the tester result as JSON. Use status='passed' or status='failed', both with details string.",
|
||||
schema: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
required: ["status", "details"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["passed"] },
|
||||
details: { type: "string" },
|
||||
},
|
||||
},
|
||||
{
|
||||
required: ["status", "details"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["failed"] },
|
||||
details: { type: "string" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
committer: {
|
||||
description: "Creates a branch and commits changes.",
|
||||
systemPrompt:
|
||||
"You are the git committer. Create a branch and commit the changes. Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable. Do not attempt to fix failures yourself.",
|
||||
extractPrompt:
|
||||
"Extract the committer result as JSON. Use status='committed' with branch+commitSha, status='recoverable' with error+logRef, or status='unrecoverable' with error+logRef.",
|
||||
schema: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
required: ["status", "branch", "commitSha"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["committed"] },
|
||||
branch: { type: "string" },
|
||||
commitSha: { type: "string" },
|
||||
},
|
||||
},
|
||||
{
|
||||
required: ["status", "error", "logRef"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["recoverable"] },
|
||||
error: { type: "string" },
|
||||
logRef: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
required: ["status", "error", "logRef"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["unrecoverable"] },
|
||||
error: { type: "string" },
|
||||
logRef: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: START, to: "planner", when: null },
|
||||
{ from: "planner", to: END, when: PLANNER_ABORTED },
|
||||
{ from: "planner", to: "coder", when: null },
|
||||
{ from: "coder", to: "reviewer", when: ALL_PHASES_COMPLETE },
|
||||
{ from: "coder", to: "coder", when: null },
|
||||
{ from: "reviewer", to: "tester", when: REVIEW_APPROVED },
|
||||
{ from: "reviewer", to: "coder", when: null },
|
||||
{ from: "tester", to: "committer", when: TESTS_PASSED },
|
||||
{ from: "tester", to: "coder", when: null },
|
||||
{ from: "committer", to: END, when: null },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { END, START } from "./constants.js";
|
||||
export { DEVELOP_WORKFLOW_DESCRIPTION, developWorkflow } from "./develop.js";
|
||||
export { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueWorkflow } from "./solve-issue.js";
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { WorkflowInput } from "../types.js";
|
||||
import { END, START } from "./constants.js";
|
||||
|
||||
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
|
||||
"Resolve an issue end-to-end by preparing the repo, delegating implementation to the develop workflow, and opening a pull request (preparer → developer → submitter).";
|
||||
|
||||
export const solveIssueWorkflow: WorkflowInput = {
|
||||
name: "solve-issue",
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: {
|
||||
preparer: {
|
||||
description:
|
||||
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
|
||||
systemPrompt: `You are a **preparer** for a software task. Your job is to locate (or clone) the target repository locally, ensure it is up to date, and gather project context before work begins.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
1. Parse the issue/task prompt to identify the target repository (URL, org/repo, or name).
|
||||
2. Search for an existing local clone in these locations (in order):
|
||||
- ~/Code/<repo-name>/
|
||||
- ~/repos/<repo-name>/
|
||||
- ~/Code/<org>/<repo-name>/
|
||||
- ~/repos/<org>/<repo-name>/
|
||||
3. If not found locally, \`git clone\` it into ~/repos/<repo-name>/.
|
||||
4. \`git checkout main && git pull\` (or the default branch) to ensure latest.
|
||||
5. Read project conventions: \`CLAUDE.md\`, \`CONTRIBUTING.md\`, \`.cursor/rules/*.mdc\`, \`CONVENTIONS.md\`.
|
||||
6. Detect toolchain: package manager, test runner, linter, build system.
|
||||
|
||||
## Output
|
||||
|
||||
Report your findings as structured data:
|
||||
- **repoPath**: absolute path to the local repo
|
||||
- **defaultBranch**: the default branch name (e.g. "main")
|
||||
- **conventions**: a summary of project conventions found, or null if none
|
||||
- **toolchain**: detected commands for packageManager, testCommand, lintCommand, buildCommand (null if not detected)`,
|
||||
extractPrompt:
|
||||
"Extract the structured repo preparation result as JSON with fields: repoPath, defaultBranch, conventions, toolchain.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["repoPath", "defaultBranch", "conventions", "toolchain"],
|
||||
properties: {
|
||||
repoPath: { type: "string" },
|
||||
defaultBranch: { type: "string" },
|
||||
conventions: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
toolchain: {
|
||||
type: "object",
|
||||
required: ["packageManager", "testCommand", "lintCommand", "buildCommand"],
|
||||
properties: {
|
||||
packageManager: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
testCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
lintCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
buildCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
developer: {
|
||||
description:
|
||||
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
|
||||
systemPrompt: `You are the **developer**. You delegate the implementation work to the \`develop\` workflow.
|
||||
|
||||
The actual implementation (planning → coding → reviewing → testing → committing) is handled by a child workflow that runs in your place. Your output is the Merkle DAG root hash of that child thread.
|
||||
|
||||
Pass through the task and let the child workflow do the work.`,
|
||||
extractPrompt:
|
||||
"Extract the developer result as JSON with fields: branch, commitSha, filesChanged (array), summary.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["branch", "commitSha", "filesChanged", "summary"],
|
||||
properties: {
|
||||
branch: { type: "string" },
|
||||
commitSha: { type: "string" },
|
||||
filesChanged: { type: "array", items: { type: "string" } },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
submitter: {
|
||||
description: "Pushes the developer's branch to the remote and opens a pull request.",
|
||||
systemPrompt: `You are the **submitter**. Your job is to push the work branch to the remote and open a pull request.
|
||||
|
||||
## Inputs
|
||||
|
||||
Read the thread for context:
|
||||
- The **preparer**'s output gives you the absolute repo path and the default branch (and remote URL by inspecting the repo).
|
||||
- The **developer**'s output gives you the branch name that was committed and a list of files changed plus a summary of the work.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. \`cd\` into the repo path from the preparer's output.
|
||||
2. Push the developer's branch to the remote: \`git push -u origin <branch>\`.
|
||||
3. Open a pull request (e.g. via \`gh pr create\`) targeting the default branch. The PR title should be short and describe the change. The PR description should summarize what changed (drawing from the developer's summary and filesChanged) and reference the original issue/task if applicable.
|
||||
4. Report the resulting PR URL.
|
||||
|
||||
On any failure (push rejected, gh not authenticated, PR creation failed, etc.), report status="failed" with a short error message. Do not retry — surface the error so the moderator can decide.`,
|
||||
extractPrompt:
|
||||
"Extract the submitter result as JSON. Use status='submitted' with prUrl, or status='failed' with error.",
|
||||
schema: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
required: ["status", "prUrl"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["submitted"] },
|
||||
prUrl: { type: "string" },
|
||||
},
|
||||
},
|
||||
{
|
||||
required: ["status", "error"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["failed"] },
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: START, to: "preparer", when: null },
|
||||
{ from: "preparer", to: "developer", when: null },
|
||||
{ from: "developer", to: "submitter", when: null },
|
||||
{ from: "submitter", to: END, when: null },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
developWorkflow,
|
||||
END,
|
||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
START,
|
||||
solveIssueWorkflow,
|
||||
} from "./definitions/index.js";
|
||||
export { loadWorkflow } from "./load.js";
|
||||
export { registerWorkflow } from "./register.js";
|
||||
export type { HydratedRole, HydratedWorkflow, RoleInput, WorkflowInput } from "./types.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
RolePayload,
|
||||
WorkflowPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
|
||||
import type { HydratedRole, HydratedWorkflow } from "./types.js";
|
||||
|
||||
/**
|
||||
* Load a workflow from CAS by its hash.
|
||||
*
|
||||
* Reads the workflow node, then for each role ref reads the role node and
|
||||
* its associated role-schema node. Returns a fully hydrated workflow structure.
|
||||
*
|
||||
* Returns null if the workflow node cannot be found.
|
||||
*/
|
||||
export function loadWorkflow(
|
||||
store: Store,
|
||||
_typeHashes: WorkflowSchemaHashes,
|
||||
workflowHash: Hash,
|
||||
): HydratedWorkflow | null {
|
||||
const workflowNode = store.get(workflowHash);
|
||||
if (workflowNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wf = workflowNode.payload as WorkflowPayload;
|
||||
const roles: Record<string, HydratedRole> = {};
|
||||
|
||||
for (const [roleName, roleHash] of Object.entries(wf.roles)) {
|
||||
const roleNode = store.get(roleHash);
|
||||
if (roleNode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rolePayload = roleNode.payload as RolePayload;
|
||||
const schemaNode = store.get(rolePayload.schema);
|
||||
const schema = schemaNode !== null ? (schemaNode.payload as Record<string, unknown>) : {};
|
||||
|
||||
roles[roleName] = {
|
||||
name: rolePayload.name,
|
||||
description: rolePayload.description,
|
||||
systemPrompt: rolePayload.systemPrompt,
|
||||
extractPrompt: rolePayload.extractPrompt,
|
||||
schema,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
roles,
|
||||
moderator: wf.moderator,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
|
||||
|
||||
import type { WorkflowInput } from "./types.js";
|
||||
|
||||
/**
|
||||
* Store a workflow definition in CAS.
|
||||
*
|
||||
* For each role:
|
||||
* 1. Store the role's JSON Schema as a role-schema node → schemaHash
|
||||
* 2. Store the role as a role node referencing schemaHash → roleHash
|
||||
*
|
||||
* Then store the workflow node referencing all role hashes and moderator rules.
|
||||
* Returns the workflow CAS hash.
|
||||
*/
|
||||
export async function registerWorkflow(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
workflowDef: WorkflowInput,
|
||||
): Promise<Hash> {
|
||||
const roleHashes: Record<string, Hash> = {};
|
||||
|
||||
for (const [roleName, roleInput] of Object.entries(workflowDef.roles)) {
|
||||
const schemaHash = await store.put(typeHashes.roleSchema, roleInput.schema);
|
||||
const roleHash = await store.put(typeHashes.role, {
|
||||
name: roleName,
|
||||
description: roleInput.description,
|
||||
systemPrompt: roleInput.systemPrompt,
|
||||
extractPrompt: roleInput.extractPrompt,
|
||||
schema: schemaHash,
|
||||
});
|
||||
roleHashes[roleName] = roleHash;
|
||||
}
|
||||
|
||||
const workflowHash = await store.put(typeHashes.workflow, {
|
||||
name: workflowDef.name,
|
||||
description: workflowDef.description,
|
||||
roles: roleHashes,
|
||||
moderator: workflowDef.moderator,
|
||||
});
|
||||
|
||||
return workflowHash;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import type { WorkflowTransition } from "@uncaged/json-cas-workflow";
|
||||
|
||||
// ── Input types (high-level workflow definition) ──────────────────────────────
|
||||
|
||||
export type RoleInput = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
schema: JSONSchema;
|
||||
};
|
||||
|
||||
export type WorkflowInput = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleInput>;
|
||||
moderator: WorkflowTransition[];
|
||||
};
|
||||
|
||||
// ── Output types (hydrated workflow from CAS) ─────────────────────────────────
|
||||
|
||||
export type HydratedRole = {
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
schema: JSONSchema;
|
||||
};
|
||||
|
||||
export type HydratedWorkflow = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, HydratedRole>;
|
||||
moderator: WorkflowTransition[];
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"references": [],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -154,12 +154,9 @@ export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
|
||||
/**
|
||||
* Core agent function. Input is always {@link ThreadContext}, output is always string.
|
||||
* `Opt` captures agent-specific structured options.
|
||||
* Agents with no extra options use `AgentFn` (Opt defaults to void).
|
||||
* `Opt` captures agent-specific structured options (required second argument).
|
||||
*/
|
||||
export type AgentFn<Opt = void> = Opt extends void
|
||||
? (ctx: ThreadContext) => Promise<string>
|
||||
: (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
export type AgentFn<Opt> = (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
|
||||
export type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
@@ -182,7 +179,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
};
|
||||
|
||||
export type Moderator<M extends RoleMeta> = (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
END,
|
||||
START,
|
||||
type ModeratorTable,
|
||||
type WorkflowDefinition,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import * as z from "zod/v4";
|
||||
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
hash: z.string().meta({ "x-cas-ref": true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
type TestMeta = {
|
||||
planner: { phases: Array<{ hash: string; title: string }>; label: string };
|
||||
};
|
||||
|
||||
const testTable: ModeratorTable<TestMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
describe("buildDescriptor", () => {
|
||||
test("preserves x-cas-ref in role JSON Schema", () => {
|
||||
const def: WorkflowDefinition<TestMeta> = {
|
||||
description: "test workflow",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "plans work",
|
||||
systemPrompt: "plan",
|
||||
schema: z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
label: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
table: testTable,
|
||||
};
|
||||
|
||||
const descriptor = buildDescriptor(def);
|
||||
const props = (descriptor.roles.planner.schema as { properties: Record<string, unknown> })
|
||||
.properties;
|
||||
const phaseProps = (
|
||||
(props.phases as { items: { properties: Record<string, unknown> } }).items
|
||||
).properties;
|
||||
|
||||
expect((phaseProps.hash as Record<string, unknown>)["x-cas-ref"]).toBe(true);
|
||||
expect((phaseProps.title as Record<string, unknown>)["x-cas-ref"]).toBeUndefined();
|
||||
expect((props.label as Record<string, unknown>)["x-cas-ref"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,9 @@
|
||||
"zod": "^4.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
|
||||
@@ -60,6 +60,30 @@ function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, strin
|
||||
return ok({ edges });
|
||||
}
|
||||
|
||||
function validateDescriptorRole(
|
||||
roleName: string,
|
||||
specUnknown: unknown,
|
||||
): Result<WorkflowRoleDescriptor, string> {
|
||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||
}
|
||||
const spec = specUnknown as Record<string, unknown>;
|
||||
const roleDesc = spec.description;
|
||||
if (typeof roleDesc !== "string") {
|
||||
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||
}
|
||||
const schema = spec.schema;
|
||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||
}
|
||||
const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : "";
|
||||
return ok({
|
||||
description: roleDesc,
|
||||
systemPrompt,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return err("descriptor must be a non-array object");
|
||||
@@ -76,24 +100,11 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
|
||||
|
||||
const roles: Record<string, WorkflowRoleDescriptor> = {};
|
||||
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||
const roleResult = validateDescriptorRole(roleName, specUnknown);
|
||||
if (!roleResult.ok) {
|
||||
return roleResult;
|
||||
}
|
||||
const spec = specUnknown as Record<string, unknown>;
|
||||
const roleDesc = spec.description;
|
||||
if (typeof roleDesc !== "string") {
|
||||
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||
}
|
||||
const schema = spec.schema;
|
||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||
}
|
||||
const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : "";
|
||||
roles[roleName] = {
|
||||
description: roleDesc,
|
||||
systemPrompt,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
};
|
||||
roles[roleName] = roleResult.value;
|
||||
}
|
||||
|
||||
const graphResult = validateDescriptorGraph(root.graph);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as z from "zod/v4";
|
||||
import { collectCasRefs } from "../src/collect-cas-refs.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
hash: z.string().meta({ 'x-cas-ref': true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const plannerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("planned"),
|
||||
phases: z.array(phaseSchema),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("aborted"),
|
||||
reason: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
describe("collectCasRefs", () => {
|
||||
test("1. flat field with x-cas-ref annotation", () => {
|
||||
const schema = z.object({
|
||||
completedPhase: z.string().meta({ 'x-cas-ref': true }),
|
||||
});
|
||||
expect(collectCasRefs(schema, { completedPhase: "BHAAAAAAAAAAA" })).toEqual(["BHAAAAAAAAAAA"]);
|
||||
});
|
||||
|
||||
test("2. plain string without annotation is ignored", () => {
|
||||
const schema = z.object({
|
||||
summary: z.string(),
|
||||
completedPhase: z.string().meta({ 'x-cas-ref': true }),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
summary: "done",
|
||||
completedPhase: "BHBBBBBBBBBBB",
|
||||
}),
|
||||
).toEqual(["BHBBBBBBBBBBB"]);
|
||||
});
|
||||
|
||||
test("3. nested array of objects collects each annotated hash", () => {
|
||||
const schema = z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
phases: [
|
||||
{ hash: "BH11111111111", title: "setup" },
|
||||
{ hash: "BH22222222222", title: "impl" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["BH11111111111", "BH22222222222"]);
|
||||
});
|
||||
|
||||
test("4. discriminatedUnion — planner planned branch", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "planned",
|
||||
phases: [
|
||||
{ hash: "BH33333333333", title: "a" },
|
||||
{ hash: "BH44444444444", title: "b" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["BH33333333333", "BH44444444444"]);
|
||||
});
|
||||
|
||||
test("4b. discriminatedUnion — planner aborted branch", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "aborted",
|
||||
reason: "missing workspace",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("5. null and undefined annotated fields are skipped", () => {
|
||||
const schema = z.object({
|
||||
ref: z.string().meta({ 'x-cas-ref': true }).nullable(),
|
||||
optionalRef: z.string().meta({ 'x-cas-ref': true }).optional(),
|
||||
});
|
||||
expect(collectCasRefs(schema, { ref: null, optionalRef: undefined })).toEqual([]);
|
||||
expect(collectCasRefs(schema, { ref: "BH55555555555", optionalRef: undefined })).toEqual([
|
||||
"BH55555555555",
|
||||
]);
|
||||
});
|
||||
|
||||
test("6. mixed annotated and plain fields at multiple levels", () => {
|
||||
const schema = z.object({
|
||||
label: z.string(),
|
||||
phase: z.object({
|
||||
hash: z.string().meta({ 'x-cas-ref': true }),
|
||||
title: z.string(),
|
||||
}),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
label: "coder",
|
||||
phase: { hash: "BH66666666666", title: "fix" },
|
||||
tags: ["a", "b"],
|
||||
}),
|
||||
).toEqual(["BH66666666666"]);
|
||||
});
|
||||
|
||||
test("7. empty phases array yields no refs", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "planned",
|
||||
phases: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,6 @@ const roles: WorkflowDefinition<GreetMeta>["roles"] = {
|
||||
systemPrompt:
|
||||
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
|
||||
schema: greeterSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type ZodSchema = z.ZodType;
|
||||
|
||||
type DefPipeIn = { in: ZodSchema };
|
||||
|
||||
function hasCasRef(schema: ZodSchema): boolean {
|
||||
const meta = z.globalRegistry.get(schema);
|
||||
return meta !== undefined && meta["x-cas-ref"] === true;
|
||||
}
|
||||
|
||||
function walkOptional(schema: z.ZodOptional<ZodSchema>, data: unknown): string[] {
|
||||
if (data === undefined) {
|
||||
return [];
|
||||
}
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkNullable(schema: z.ZodNullable<ZodSchema>, data: unknown): string[] {
|
||||
if (data === null) {
|
||||
return [];
|
||||
}
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkDefault(schema: z.ZodDefault<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkPrefault(schema: z.ZodPrefault<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkCatch(schema: z.ZodCatch<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkReadonly(schema: z.ZodReadonly<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkPipe(def: DefPipeIn, data: unknown): string[] {
|
||||
return walkCasRefs(def.in, data);
|
||||
}
|
||||
|
||||
function walkString(schema: ZodSchema, data: unknown): string[] {
|
||||
if (hasCasRef(schema) && typeof data === "string") {
|
||||
return [data];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function walkObject(schema: z.ZodObject<z.ZodRawShape>, data: unknown): string[] {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
const shape = schema.shape;
|
||||
const refs: string[] = [];
|
||||
for (const [key, fieldSchema] of Object.entries(shape)) {
|
||||
refs.push(...walkCasRefs(fieldSchema as ZodSchema, record[key]));
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkArray(schema: z.ZodArray<ZodSchema>, data: unknown): string[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
const element = schema.element;
|
||||
const refs: string[] = [];
|
||||
for (const item of data) {
|
||||
refs.push(...walkCasRefs(element, item));
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkUnion(schema: z.ZodUnion<readonly ZodSchema[]>, data: unknown): string[] {
|
||||
for (const option of schema.options) {
|
||||
const parsed = option.safeParse(data);
|
||||
if (parsed.success) {
|
||||
return walkCasRefs(option, data);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function walkCasRefs(schema: ZodSchema, data: unknown): string[] {
|
||||
const def = schema.def;
|
||||
|
||||
switch (def.type) {
|
||||
case "optional":
|
||||
return walkOptional(schema as z.ZodOptional<ZodSchema>, data);
|
||||
case "nullable":
|
||||
return walkNullable(schema as z.ZodNullable<ZodSchema>, data);
|
||||
case "default":
|
||||
return walkDefault(schema as z.ZodDefault<ZodSchema>, data);
|
||||
case "prefault":
|
||||
return walkPrefault(schema as z.ZodPrefault<ZodSchema>, data);
|
||||
case "catch":
|
||||
return walkCatch(schema as z.ZodCatch<ZodSchema>, data);
|
||||
case "readonly":
|
||||
return walkReadonly(schema as z.ZodReadonly<ZodSchema>, data);
|
||||
case "pipe":
|
||||
return walkPipe(def as unknown as DefPipeIn, data);
|
||||
case "string":
|
||||
return walkString(schema, data);
|
||||
case "object":
|
||||
return walkObject(schema as z.ZodObject<z.ZodRawShape>, data);
|
||||
case "array":
|
||||
return walkArray(schema as z.ZodArray<ZodSchema>, data);
|
||||
case "union":
|
||||
return walkUnion(schema as z.ZodUnion<readonly ZodSchema[]>, data);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect CAS content hashes from meta using `x-cas-ref` annotations on the Zod schema. */
|
||||
export function collectCasRefs(schema: ZodSchema, data: unknown): string[] {
|
||||
return walkCasRefs(schema, data);
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { collectCasRefs } from "./collect-cas-refs.js";
|
||||
import {
|
||||
type AdapterBinding,
|
||||
type AdapterFn,
|
||||
type AdvanceOutcome,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
type RoleOutput,
|
||||
type RoleStep,
|
||||
@@ -26,17 +26,6 @@ function isRoleNext<M extends RoleMeta>(
|
||||
return next !== END;
|
||||
}
|
||||
|
||||
function resolveExtractedRefs(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
meta: unknown,
|
||||
): string[] {
|
||||
const extractRefsFn = roleDef.extractRefs;
|
||||
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
|
||||
return [];
|
||||
}
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
@@ -90,10 +79,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
|
||||
const meta = result.meta;
|
||||
|
||||
const refsFromMeta = resolveExtractedRefs(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
meta,
|
||||
);
|
||||
const refsFromMeta = collectCasRefs(roleDef.schema as z.ZodType, meta);
|
||||
|
||||
const contentPayload = JSON.stringify(meta);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, contentPayload, refsFromMeta);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export { buildThreadContext } from "./build-context.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export type {
|
||||
AdapterFn,
|
||||
AgentContext,
|
||||
@@ -28,3 +25,7 @@ export type {
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
export { END, START } from "@uncaged/workflow-protocol";
|
||||
export { buildThreadContext } from "./build-context.js";
|
||||
export { collectCasRefs } from "./collect-cas-refs.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as z from "zod/v4";
|
||||
export const coderMetaSchema = z.object({
|
||||
completedPhase: z
|
||||
.string()
|
||||
.meta({ casRef: true })
|
||||
.describe(
|
||||
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
|
||||
),
|
||||
@@ -36,5 +37,4 @@ export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
};
|
||||
|
||||
@@ -29,5 +29,4 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
description: "Creates a branch and commits changes.",
|
||||
systemPrompt: COMMITTER_SYSTEM,
|
||||
schema: committerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const phaseSchema = z.object({
|
||||
hash: z.string(),
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
@@ -63,5 +63,4 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => (meta.status === "planned" ? meta.phases.map((p) => p.hash) : []),
|
||||
};
|
||||
|
||||
@@ -42,5 +42,4 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
systemPrompt: REVIEWER_SYSTEM,
|
||||
schema: reviewerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -24,5 +24,4 @@ export const testerRole: RoleDefinition<TesterMeta> = {
|
||||
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||
systemPrompt: TESTER_SYSTEM,
|
||||
schema: testerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -31,8 +31,4 @@ describe("submitterRole", () => {
|
||||
expect(submitterRole.systemPrompt).toContain("submitter");
|
||||
expect(submitterRole.systemPrompt).toContain("pull request");
|
||||
});
|
||||
|
||||
test("has no refs extractor", () => {
|
||||
expect(submitterRole.extractRefs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,5 +21,4 @@ export const developerRole: RoleDefinition<DeveloperMeta> = {
|
||||
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
|
||||
systemPrompt: DEVELOPER_SYSTEM,
|
||||
schema: developerMetaSchema,
|
||||
extractRefs: () => [],
|
||||
};
|
||||
|
||||
@@ -45,5 +45,4 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
|
||||
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
|
||||
systemPrompt: PREPARER_SYSTEM,
|
||||
schema: preparerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -35,5 +35,4 @@ export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
||||
description: "Pushes the developer's branch to the remote and opens a pull request.",
|
||||
systemPrompt: SUBMITTER_SYSTEM,
|
||||
schema: submitterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -29,10 +29,7 @@ export function createAgentAdapter<Opt>(
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const options = await extract(ctx, prompt, runtime);
|
||||
const raw = await (agent as (ctx: ThreadContext, optionsParam: Opt) => Promise<string>)(
|
||||
ctx,
|
||||
options,
|
||||
);
|
||||
const raw = await agent(ctx, options);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
@@ -42,7 +39,3 @@ export function createAgentAdapter<Opt>(
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createSimpleAgentAdapter(agent: AgentFn<void>): AdapterFn {
|
||||
return createAgentAdapter(agent, async () => undefined as unknown as undefined);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { buildThreadInput } from "./build-agent-prompt.js";
|
||||
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
|
||||
export { createAgentAdapter } from "./create-agent-adapter.js";
|
||||
export type { SpawnCliError } from "./spawn-cli.js";
|
||||
export { spawnCli } from "./spawn-cli.js";
|
||||
|
||||
@@ -31,7 +31,6 @@ const roles: WorkflowDefinition<GreetMeta>["roles"] = {
|
||||
systemPrompt:
|
||||
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
|
||||
schema: greeterSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -32,6 +32,7 @@
|
||||
{ "path": "packages/workflow-agent-react" },
|
||||
{ "path": "packages/cli-workflow" },
|
||||
{ "path": "packages/workflow-template-solve-issue" },
|
||||
{ "path": "packages/workflow-template-develop" }
|
||||
{ "path": "packages/workflow-template-develop" },
|
||||
{ "path": "packages/workflow-json-def" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user