Compare commits

..

6 Commits

Author SHA1 Message Date
xiaoju 6306b23a9f refactor: replace extractRefs with schema casRef annotations
Migrate all templates to use .meta({ casRef: true }) on Zod schema
fields instead of manual extractRefs functions. Remove extractRefs
from RoleDefinition type entirely.

- develop: planner phases[].hash, coder completedPhase annotated
- solve-issue, smoke, init templates: extractRefs removed
- create-workflow.ts: uses collectCasRefs(schema, meta)
- RoleDefinition: extractRefs field removed (breaking)

218 tests pass, 0 fail.

Fixes #289, Refs #285
2026-05-16 10:48:45 +00:00
xiaomo 6bb8cf8315 Merge pull request 'feat: add collectCasRefs — schema-level CAS ref annotation (Phase 1)' (#288) from feat/285-cas-ref-annotation into main 2026-05-16 10:43:16 +00:00
xiaoju 93b7947d7c feat: add collectCasRefs — extract CAS refs from schema meta annotations
Replaces manual extractRefs functions with declarative schema-level
casRef annotations. Walks Zod v4 schemas recursively, collecting
string values from fields marked with .meta({ casRef: true }).

Supports: objects, arrays, discriminatedUnion, nullable/optional.

8/8 test cases pass (flat, nested, union, null, mixed).

Refs #285, addresses #286
2026-05-16 10:42:24 +00:00
xingyue 9584a86fb7 Merge pull request 'chore: fix biome cognitive complexity warnings' (#287) from chore/fix-biome-complexity-warnings into main 2026-05-16 10:40:27 +00:00
Scott Wei defc0afc27 chore: fix biome cognitive complexity warnings
Refactor dashboard graph/schema helpers and descriptor role validation
into smaller functions so bun run check passes without warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:33:00 +08:00
xiaomo 9f6633d5bf Merge pull request 'refactor(workflow-protocol): require AgentFn Opt generic' (#284) from refactor/agent-fn-required-opt into main 2026-05-16 10:27:07 +00:00
22 changed files with 678 additions and 384 deletions
@@ -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,
};
`;
}
+1 -2
View File
@@ -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
@@ -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 };
}
-1
View File
@@ -179,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> = (
@@ -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({ casRef: 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 casRef annotation", () => {
const schema = z.object({
completedPhase: z.string().meta({ casRef: 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({ casRef: 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({ casRef: true }).nullable(),
optionalRef: z.string().meta({ casRef: 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({ casRef: 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.casRef === 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 `casRef` 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
View File
@@ -26,5 +26,6 @@ export type {
} 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,
};
-1
View File
@@ -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,
},
};