feat: Phase 3 — engine read path + runtime context builder

- Add buildThreadContext(headHash, cas) to workflow-runtime
- Expand extract phase to return { meta, contentPayload, refs[] }
- Add parseCasThreadNode() to workflow-cas for node type parsing
- Update createWorkflow to write ContentMerkleNode with artifact refs
- Tests: 4 pass (build-context + extract-refs)
- Biome format pass on all files

Refs #155, closes #158

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-09 08:00:24 +00:00
parent 81c582ae0e
commit 26cf51366f
51 changed files with 701 additions and 234 deletions
@@ -2,10 +2,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
import {
cmdAdd,
@@ -5,8 +5,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { garbageCollectCas } from "@uncaged/workflow-execute";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { cmdThreadRemove } from "../src/commands/thread/index.js";
import { pathExists } from "../src/fs-utils.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Result } from "@uncaged/workflow-protocol";
import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute";
import type { Result } from "@uncaged/workflow-protocol";
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
return garbageCollectCas(storageRoot);
@@ -1,6 +1,6 @@
import { createCasStore } from "@uncaged/workflow-cas";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
export async function cmdCasGet(
storageRoot: string,
@@ -1,6 +1,6 @@
import { createCasStore } from "@uncaged/workflow-cas";
import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
@@ -1,6 +1,6 @@
import { createCasStore } from "@uncaged/workflow-cas";
import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
export async function cmdCasPut(
storageRoot: string,
+1 -1
View File
@@ -1,6 +1,6 @@
import { createCasStore } from "@uncaged/workflow-cas";
import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
@@ -1,6 +1,6 @@
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
import { garbageCollectCas } from "@uncaged/workflow-execute";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { Hono } from "hono";
export function createCasRoutes(storageRoot: string): Hono {
@@ -1,8 +1,7 @@
import { join } from "node:path";
import { buildForkPlan } from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { buildForkPlan } from "@uncaged/workflow-execute";
import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
import { resolveThreadDataPath } from "../../thread-scan.js";
@@ -1,11 +1,10 @@
import { watch } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { tryParseRoleStepRecord, tryParseWorkflowResultRecord } from "@uncaged/workflow-execute";
import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
import { printCliError, printCliLine } from "../../cli-output.js";
@@ -1,8 +1,7 @@
import { unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { garbageCollectCas } from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { resolveThreadDataPath } from "../../thread-scan.js";
@@ -1,8 +1,8 @@
import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
import { generateUlid } from "@uncaged/workflow-util";
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -1,8 +1,7 @@
import { readFile, stat } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { hashWorkflowBundleBytes } from "@uncaged/workflow-cas";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import {
extractBundleExports,
readWorkflowRegistry,
+1 -2
View File
@@ -2,9 +2,8 @@ import { type ChildProcess, spawn } from "node:child_process";
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
import { createConnection } from "node:net";
import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
@@ -7,7 +7,11 @@ const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
_schema: z.ZodType<T>,
_prompt: string,
_ctx: ExtractContext,
): Promise<T> => ({ workspace: "/tmp" }) as unknown as T;
): Promise<{ meta: T; contentPayload: string; refs: string[] }> => ({
meta: { workspace: "/tmp" } as unknown as T,
contentPayload: "",
refs: [],
});
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
+2 -1
View File
@@ -48,11 +48,12 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
...ctx,
agentContent: "",
};
const { workspace } = await config.extract(
const extracted = await config.extract(
cursorWorkspaceSchema,
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
extractCtx,
);
const { workspace } = extracted.meta;
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"-p",
+1 -3
View File
@@ -1,8 +1,6 @@
import { Buffer } from "node:buffer";
import XXH from "xxhashjs";
import { encodeUint64AsCrockford } from "@uncaged/workflow-util";
import XXH from "xxhashjs";
function digestToUint64(digest: { toString(radix?: number): string }): bigint {
const hex = digest.toString(16).padStart(16, "0");
+2
View File
@@ -10,8 +10,10 @@ export {
putThreadMerkleNode,
serializeMerkleNode,
} from "./merkle.js";
export type { ParsedCasThreadNode } from "./nodes.js";
export {
isCasNodeYaml,
parseCasThreadNode,
putContentNodeWithRefs,
putStartNode,
putStateNode,
+100 -1
View File
@@ -1,9 +1,108 @@
import type { ContentMerkleNode, StartNode, StateNode } from "@uncaged/workflow-protocol";
import type {
ContentMerkleNode,
StartNode,
StartNodePayload,
StateNode,
StateNodePayload,
} from "@uncaged/workflow-protocol";
import { parse, stringify } from "yaml";
import { collectRefs } from "./collect-refs.js";
import type { CasStore } from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isStartPayload(value: unknown): value is StartNodePayload {
if (!isRecord(value)) {
return false;
}
return (
typeof value.name === "string" &&
typeof value.hash === "string" &&
typeof value.maxRounds === "number" &&
typeof value.depth === "number"
);
}
function isStatePayload(value: unknown): value is StateNodePayload {
if (!isRecord(value)) {
return false;
}
const compact = value.compact;
if (!(compact === null || typeof compact === "string")) {
return false;
}
const ancestors = value.ancestors;
if (!Array.isArray(ancestors) || !ancestors.every((h) => typeof h === "string")) {
return false;
}
const meta = value.meta;
if (!isRecord(meta)) {
return false;
}
return (
typeof value.role === "string" &&
typeof value.start === "string" &&
typeof value.content === "string" &&
typeof value.timestamp === "number"
);
}
/** Parses a YAML CAS blob into a typed RFC v3 thread node (or legacy content layout with `children`). */
export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null {
let raw: unknown;
try {
raw = parse(yamlText) as unknown;
} catch {
return null;
}
if (!isRecord(raw)) {
return null;
}
const type = raw.type;
if (type !== "start" && type !== "state" && type !== "content") {
return null;
}
let refsRaw: unknown = raw.refs;
if (refsRaw === undefined && type === "content") {
refsRaw = raw.children;
}
if (!Array.isArray(refsRaw) || !refsRaw.every((r) => typeof r === "string")) {
return null;
}
const refs = refsRaw as string[];
if (type === "content") {
if (typeof raw.payload !== "string") {
return null;
}
const node: ContentMerkleNode = { type: "content", payload: raw.payload, refs: [...refs] };
return { kind: "content", node };
}
if (type === "start") {
if (!isStartPayload(raw.payload)) {
return null;
}
const node: StartNode = { type: "start", payload: raw.payload, refs: [...refs] };
return { kind: "start", node };
}
if (!isStatePayload(raw.payload)) {
return null;
}
const node: StateNode = { type: "state", payload: raw.payload, refs: [...refs] };
return { kind: "state", node };
}
export type ParsedCasThreadNode =
| { kind: "start"; node: StartNode }
| { kind: "state"; node: StateNode }
| { kind: "content"; node: ContentMerkleNode };
/** YAML-serialize a CAS node carrying `{type, payload, refs}` (RFC v3 thread storage format). */
export function serializeCasNode(node: StartNode | StateNode | ContentMerkleNode): string {
return stringify({ type: node.type, payload: node.payload, refs: node.refs }, { indent: 2 });
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import { type ExtractContext, START } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createExtract } from "../src/extract/extract-fn.js";
function installPlainJsonExtractMock(meta: Record<string, unknown>): () => void {
const origFetch = globalThis.fetch;
const mockFetch = async (): Promise<Response> =>
new Response(
JSON.stringify({
choices: [{ message: { content: JSON.stringify(meta) } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
return () => {
globalThis.fetch = origFetch;
};
}
describe("createExtract — ExtractResult shape", () => {
let restoreFetch: (() => void) | null = null;
afterEach(() => {
restoreFetch?.();
restoreFetch = null;
});
test("returns meta, contentPayload, and refs[]", async () => {
restoreFetch = installPlainJsonExtractMock({ confidence: 0.9 });
const dir = await mkdtemp(join(tmpdir(), "wf-extract-refs-"));
try {
const cas = createCasStore(join(dir, "cas"));
const extract = createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "key", model: "m" },
{ cas },
);
const schema = z.object({ confidence: z.number() });
const ctx: ExtractContext = {
threadId: "01THREADTESTAAAAAAAAAAAAAA",
depth: 0,
start: {
role: START,
content: "task text",
meta: { maxRounds: 10 },
timestamp: 100,
},
steps: [],
currentRole: { name: "analyst", systemPrompt: "be precise" },
agentContent: "model says hello",
};
const out = await extract(schema, "extract fields", ctx);
expect(out.meta).toEqual({ confidence: 0.9 });
expect(out.contentPayload).toBe("model says hello");
expect(Array.isArray(out.refs)).toBe(true);
expect(out.refs).toEqual([]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
@@ -1,10 +1,9 @@
import * as z from "zod/v4";
import { resolveModel } from "@uncaged/workflow-register";
import { extractFunctionToolFromZodSchema } from "../extract/index.js";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import { resolveModel } from "@uncaged/workflow-register";
import { err, type LogFn, ok, type Result } from "@uncaged/workflow-util";
import * as z from "zod/v4";
import { extractFunctionToolFromZodSchema } from "../extract/index.js";
import type { SupervisorDecision } from "./types.js";
@@ -1,9 +1,12 @@
import { mkdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type Socket } from "node:net";
import { dirname, join } from "node:path";
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "@uncaged/workflow-register";
import { createCasStore } from "@uncaged/workflow-cas";
import {
ensureUncagedWorkflowSymlink,
importWorkflowBundleModule,
} from "@uncaged/workflow-register";
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
import {
createLogger,
err,
@@ -1,7 +1,12 @@
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type {
ExtractContext,
ExtractFn,
ExtractResult,
LlmProvider,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
export type ExtractDeps = {
@@ -121,7 +126,7 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
): Promise<T> => {
): Promise<ExtractResult<T>> => {
const text = await buildExtractUserContent(ctx, prompt, deps);
const result = await reactor({
thread: { cas: deps.cas },
@@ -131,6 +136,10 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
if (!result.ok) {
throw new Error(`extract failed: ${result.error}`);
}
return result.value;
return {
meta: result.value,
contentPayload: ctx.agentContent,
refs: [],
};
};
}
@@ -1,6 +1,5 @@
import * as z from "zod/v4";
import { err, ok, type Result } from "@uncaged/workflow-util";
import * as z from "zod/v4";
import type { LlmError, LlmExtractArgs } from "./types.js";
+2 -4
View File
@@ -21,15 +21,13 @@ export type {
ThreadPauseGate,
} from "./engine/types.js";
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
export {
buildExtractUserContent,
createExtract,
type ExtractThreadContext,
} from "./extract/index.js";
export {
extractFunctionToolFromZodSchema,
llmErrorToCause,
llmExtract,
} from "./extract/index.js";
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
export { workflowAsAgent, type WorkflowAsAgentOptions } from "./workflow-as-agent.js";
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
@@ -1,17 +1,20 @@
import { join } from "node:path";
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
import { extractBundleExports } from "@uncaged/workflow-register";
import { createCasStore } from "@uncaged/workflow-cas";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread } from "./engine/index.js";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
import {
extractBundleExports,
getRegisteredWorkflow,
readWorkflowRegistry,
} from "@uncaged/workflow-register";
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
import {
createLogger,
generateUlid,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
} from "@uncaged/workflow-util";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread } from "./engine/index.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
+3
View File
@@ -3,7 +3,9 @@
export type {
ContentMerkleNode,
StartNode,
StartNodePayload,
StateNode,
StateNodePayload,
} from "./cas-types.js";
export type {
@@ -14,6 +16,7 @@ export type {
CasStore,
ExtractContext,
ExtractFn,
ExtractResult,
LlmProvider,
Moderator,
ModeratorContext,
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Result } from "./types.js";
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
return { ok: false, error };
}
+75 -68
View File
@@ -12,10 +12,10 @@ export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error:
// ── CAS ────────────────────────────────────────────────────────────
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
// ── Workflow Descriptor ────────────────────────────────────────────
@@ -23,13 +23,13 @@ export type CasStore = {
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
description: string;
schema: WorkflowRoleSchema;
};
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
// ── Role & Thread ──────────────────────────────────────────────────
@@ -37,131 +37,138 @@ export type WorkflowDescriptor = {
export type RoleMeta = Record<string, Record<string, unknown>>;
export type RoleOutput = {
role: string;
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
role: string;
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
};
export type StartStep = {
role: typeof START;
content: string;
meta: { maxRounds: number };
timestamp: number;
role: typeof START;
content: string;
meta: { maxRounds: number };
timestamp: number;
};
export type RoleStep<M extends RoleMeta> = {
[K in keyof M & string]: {
role: K;
meta: M[K];
contentHash: string;
refs: string[];
timestamp: number;
};
[K in keyof M & string]: {
role: K;
meta: M[K];
contentHash: string;
refs: string[];
timestamp: number;
};
}[keyof M & string];
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
depth: number;
start: StartStep;
steps: RoleStep<M>[];
threadId: string;
depth: number;
start: StartStep;
steps: RoleStep<M>[];
};
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
currentRole: {
name: string;
systemPrompt: string;
};
currentRole: {
name: string;
systemPrompt: string;
};
};
export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
agentContent: string;
agentContent: string;
};
// ── Workflow Completion ────────────────────────────────────────────
export type WorkflowCompletion = {
returnCode: number;
summary: string;
returnCode: number;
summary: string;
};
export type WorkflowResult = WorkflowCompletion & {
rootHash: string;
rootHash: string;
};
// ── LLM Provider ───────────────────────────────────────────────────
export type LlmProvider = {
baseUrl: string;
apiKey: string;
model: string;
baseUrl: string;
apiKey: string;
model: string;
};
export type ProviderConfig = {
baseUrl: string;
apiKey: string;
baseUrl: string;
apiKey: string;
};
export type ResolvedModel = {
baseUrl: string;
apiKey: string;
model: string;
baseUrl: string;
apiKey: string;
model: string;
};
export type WorkflowConfig = {
maxDepth: number;
supervisorInterval: number;
providers: Record<string, ProviderConfig>;
models: Record<string, string>;
maxDepth: number;
supervisorInterval: number;
providers: Record<string, ProviderConfig>;
models: Record<string, string>;
};
// ── Functions ──────────────────────────────────────────────────────
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
export type ExtractResult<T extends Record<string, unknown>> = {
meta: T;
contentPayload: string;
refs: string[];
};
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<T>;
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<ExtractResult<T>>;
export type AgentFn = (ctx: AgentContext) => Promise<string>;
export type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// ── Workflow Runtime & Definition ──────────────────────────────────
export type WorkflowRuntime = {
cas: CasStore;
extract: ExtractFn;
cas: CasStore;
extract: ExtractFn;
};
export type WorkflowFn = (
thread: ThreadContext,
runtime: WorkflowRuntime,
thread: ThreadContext,
runtime: WorkflowRuntime,
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
extractRefs: ((meta: Meta) => string[]) | null;
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
extractRefs: ((meta: Meta) => string[]) | null;
};
export type Moderator<M extends RoleMeta> = (
ctx: ModeratorContext<M>,
ctx: ModeratorContext<M>,
) => (keyof M & string) | typeof END;
export type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
};
export type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
@@ -1,6 +1,5 @@
import type * as z from "zod/v4";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type * as z from "zod/v4";
import type {
ChatMessage,
+1 -2
View File
@@ -1,6 +1,5 @@
import type * as z from "zod/v4";
import type { Result } from "@uncaged/workflow-protocol";
import type * as z from "zod/v4";
export type ToolCall = {
id: string;
+1 -3
View File
@@ -5,7 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" }
]
"references": [{ "path": "../workflow-protocol" }]
}
@@ -1,4 +1,5 @@
import { isBuiltin } from "node:module";
import { err, ok, type Result } from "@uncaged/workflow-util";
import type {
CallExpression,
ExportAllDeclaration,
@@ -12,8 +13,6 @@ import type {
} from "acorn";
import * as acorn from "acorn";
import { err, ok, type Result } from "@uncaged/workflow-util";
import type { WorkflowBundleValidationInput } from "./types.js";
/** Acorn Node with index-access for property traversal. */
@@ -2,9 +2,9 @@ import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-protocol"
export type {
WorkflowDescriptor,
WorkflowFn,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowFn,
} from "@uncaged/workflow-protocol";
export type WorkflowBundleValidationInput = {
+17 -19
View File
@@ -1,12 +1,3 @@
export {
buildDescriptor,
importWorkflowBundleModule,
validateWorkflowBundle,
ensureUncagedWorkflowSymlink,
extractBundleExports,
stringifyWorkflowDescriptor,
validateWorkflowDescriptor,
} from "./bundle/index.js";
export type {
ExtractBundleExportsOptions,
ExtractedBundleExports,
@@ -15,7 +6,23 @@ export type {
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./bundle/index.js";
export {
buildDescriptor,
ensureUncagedWorkflowSymlink,
extractBundleExports,
importWorkflowBundleModule,
stringifyWorkflowDescriptor,
validateWorkflowBundle,
validateWorkflowDescriptor,
} from "./bundle/index.js";
export type { ProviderConfig, ResolvedModel } from "./config/index.js";
export { resolveModel, splitProviderModelRef } from "./config/index.js";
export type {
WorkflowConfig,
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
} from "./registry/index.js";
export {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
@@ -28,12 +35,3 @@ export {
workflowRegistryPath,
writeWorkflowRegistry,
} from "./registry/index.js";
export type {
WorkflowConfig,
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
} from "./registry/index.js";
export { resolveModel, splitProviderModelRef } from "./config/index.js";
export type { ProviderConfig, ResolvedModel } from "./config/index.js";
@@ -1,6 +1,6 @@
import type { ProviderConfig } from "@uncaged/workflow-protocol";
import { splitProviderModelRef } from "../config/index.js";
import { createLogger, err, ok, type Result } from "@uncaged/workflow-util";
import { splitProviderModelRef } from "../config/index.js";
import type {
WorkflowConfig,
WorkflowHistoryEntry,
@@ -1,8 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { parseDocument, stringify } from "yaml";
import { err, ok, type Result } from "@uncaged/workflow-util";
import { parseDocument, stringify } from "yaml";
import { normalizeWorkflowRegistryRoot } from "./registry-normalize.js";
import type { WorkflowHistoryEntry, WorkflowRegistryEntry, WorkflowRegistryFile } from "./types.js";
+1 -4
View File
@@ -1,8 +1,5 @@
{
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-util" }
],
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }],
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createCasStore,
putContentNodeWithRefs,
putStartNode,
putStateNode,
} from "@uncaged/workflow-cas";
import { buildThreadContext, END, START } from "../src/index.js";
describe("buildThreadContext", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "wf-build-ctx-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
test("walks ancestor chain, resolves prompt, orders steps chronologically", async () => {
const cas = createCasStore(join(dir, "cas"));
const promptHash = await cas.put("hello-task");
const bundleHash = "BHAAAAAAAAAAA";
const startHash = await putStartNode(
cas,
{ name: "demo", hash: bundleHash, maxRounds: 99, depth: 2 },
promptHash,
);
const art = await cas.put("artifact-a");
const chPlan = await putContentNodeWithRefs(cas, "plan body", [art]);
const statePlan = await putStateNode(cas, {
role: "planner",
meta: { phase: 1 },
start: startHash,
content: chPlan,
ancestors: [],
compact: null,
timestamp: 1000,
});
const chCode = await putContentNodeWithRefs(cas, "code body", []);
const stateCode = await putStateNode(cas, {
role: "coder",
meta: { phase: 2 },
start: startHash,
content: chCode,
ancestors: [statePlan],
compact: null,
timestamp: 2000,
});
const ctx = await buildThreadContext(stateCode, cas);
expect(ctx.threadId).toBe("");
expect(ctx.depth).toBe(2);
expect(ctx.start.role).toBe(START);
expect(ctx.start.content).toBe("hello-task");
expect(ctx.start.meta.maxRounds).toBe(99);
expect(ctx.steps.map((s) => s.role)).toEqual(["planner", "coder"]);
expect(ctx.steps[0]?.refs).toEqual([art]);
expect(ctx.steps[1]?.refs).toEqual([]);
expect(ctx.steps[0]?.timestamp).toBe(1000);
expect(ctx.steps[1]?.timestamp).toBe(2000);
});
test("StartNode head yields empty steps", async () => {
const cas = createCasStore(join(dir, "cas"));
const promptHash = await cas.put("only-prompt");
const startHash = await putStartNode(
cas,
{ name: "solo", hash: "BHBBBBBBBBBBB", maxRounds: 3, depth: 1 },
promptHash,
);
const ctx = await buildThreadContext(startHash, cas);
expect(ctx.steps).toEqual([]);
expect(ctx.start.content).toBe("only-prompt");
expect(ctx.depth).toBe(1);
expect(ctx.start.meta.maxRounds).toBe(3);
});
test("omits __end__ states from steps", async () => {
const cas = createCasStore(join(dir, "cas"));
const promptHash = await cas.put("task");
const bundleHash = "BHCCCCCCCCCCC";
const startHash = await putStartNode(
cas,
{ name: "demo", hash: bundleHash, maxRounds: 10, depth: 0 },
promptHash,
);
const ch1 = await putContentNodeWithRefs(cas, "step-one", []);
const state1 = await putStateNode(cas, {
role: "worker",
meta: { done: false },
start: startHash,
content: ch1,
ancestors: [],
compact: null,
timestamp: 500,
});
const endContent = await putContentNodeWithRefs(cas, "finished", []);
const endState = await putStateNode(cas, {
role: END,
meta: { returnCode: 0, summary: "finished" },
start: startHash,
content: endContent,
ancestors: [state1],
compact: null,
timestamp: 600,
});
const ctx = await buildThreadContext(endState, cas);
expect(ctx.steps.map((s) => s.role)).toEqual(["worker"]);
});
});
+1
View File
@@ -8,6 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-protocol": "workspace:*"
},
"peerDependencies": {
@@ -0,0 +1,153 @@
import { getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
import type {
CasStore,
RoleMeta,
RoleStep,
StartNode,
StateNode,
ThreadContext,
} from "@uncaged/workflow-protocol";
import { END, START } from "@uncaged/workflow-protocol";
async function loadParsedNode(cas: CasStore, hash: string) {
const yamlText = await cas.get(hash);
if (yamlText === null) {
return null;
}
return parseCasThreadNode(yamlText);
}
async function resolvePromptText(cas: CasStore, promptHash: string): Promise<string> {
const text = await getContentMerklePayload(cas, promptHash);
if (text !== null) {
return text;
}
throw new Error(`buildThreadContext: could not resolve prompt text at ${promptHash}`);
}
async function collectStateChainFromHead(cas: CasStore, headHash: string): Promise<StateNode[]> {
const reversed: StateNode[] = [];
let hash: string | null = headHash;
while (hash !== null) {
const parsed = await loadParsedNode(cas, hash);
if (parsed === null || parsed.kind !== "state") {
throw new Error(`buildThreadContext: expected state node at ${hash}`);
}
reversed.push(parsed.node);
const anc = parsed.node.payload.ancestors;
hash = anc.length > 0 ? anc[0] : null;
}
reversed.reverse();
return reversed;
}
async function threadFromStartHead<M extends RoleMeta>(
node: StartNode,
cas: CasStore,
): Promise<ThreadContext<M>> {
const promptHash = node.refs[0];
if (promptHash === undefined) {
throw new Error("buildThreadContext: StartNode missing refs[0] prompt");
}
const prompt = await resolvePromptText(cas, promptHash);
const p = node.payload;
return {
threadId: "",
depth: p.depth,
start: {
role: START,
content: prompt,
meta: { maxRounds: p.maxRounds },
timestamp: 0,
},
steps: [],
};
}
async function buildRoleStepsFromStates<M extends RoleMeta>(
chronologicalStates: StateNode[],
cas: CasStore,
): Promise<RoleStep<M>[]> {
const steps: RoleStep<M>[] = [];
for (const st of chronologicalStates) {
if (st.payload.role === END) {
continue;
}
const contentParsed = await loadParsedNode(cas, st.payload.content);
if (contentParsed === null || contentParsed.kind !== "content") {
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
}
steps.push({
role: st.payload.role,
meta: st.payload.meta,
contentHash: st.payload.content,
refs: [...contentParsed.node.refs],
timestamp: st.payload.timestamp,
} as RoleStep<M>);
}
return steps;
}
async function threadFromStateHead<M extends RoleMeta>(
headHash: string,
cas: CasStore,
): Promise<ThreadContext<M>> {
const chronologicalStates = await collectStateChainFromHead(cas, headHash);
const firstState = chronologicalStates[0];
if (firstState === undefined) {
throw new Error("buildThreadContext: empty state chain");
}
const startBlob = await loadParsedNode(cas, firstState.payload.start);
if (startBlob === null || startBlob.kind !== "start") {
throw new Error(`buildThreadContext: StartNode missing at ${firstState.payload.start}`);
}
const promptHash = startBlob.node.refs[0];
if (promptHash === undefined) {
throw new Error("buildThreadContext: StartNode missing refs[0] prompt");
}
const prompt = await resolvePromptText(cas, promptHash);
const sp = startBlob.node.payload;
const steps = await buildRoleStepsFromStates<M>(chronologicalStates, cas);
const firstTs = steps[0]?.timestamp ?? 0;
return {
threadId: "",
depth: sp.depth,
start: {
role: START,
content: prompt,
meta: { maxRounds: sp.maxRounds },
timestamp: firstTs,
},
steps,
};
}
/**
* Reconstructs {@link ThreadContext} by walking the CAS state chain from {@link headHash}.
*
* Walks each {@link StateNode} via `payload.ancestors[0]` until the ancestor list is empty,
* resolves the prompt from the shared {@link StartNode} (`refs[0]` → prompt blob), and builds
* steps from non-`__end__` states in chronological order.
*
* `threadId` is set to `""` — callers that load from `threads.json` should overwrite it.
*/
export async function buildThreadContext<M extends RoleMeta = RoleMeta>(
headHash: string,
cas: CasStore,
): Promise<ThreadContext<M>> {
const headParsed = await loadParsedNode(cas, headHash);
if (headParsed === null) {
throw new Error(`buildThreadContext: missing or invalid CAS blob ${headHash}`);
}
if (headParsed.kind === "start") {
return threadFromStartHead(headParsed.node, cas);
}
if (headParsed.kind !== "state") {
throw new Error(`buildThreadContext: head ${headHash} must be start or state node`);
}
return threadFromStateHead(headHash, cas);
}
@@ -1,3 +1,4 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type * as z from "zod/v4";
import {
@@ -5,7 +6,6 @@ import {
type AgentBinding,
type AgentContext,
type AgentFn,
type CasStore,
END,
type ExtractContext,
type ModeratorContext,
@@ -38,8 +38,16 @@ function resolveExtractedRefs(
return extractRefsFn(meta as Record<string, unknown>);
}
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
return store.put(raw);
function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const h of [...a, ...b]) {
if (!seen.has(h)) {
seen.add(h);
out.push(h);
}
}
return out;
}
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
@@ -86,23 +94,29 @@ async function advanceOneRound<M extends RoleMeta>(
agentContent: raw,
};
const meta = await runtime.extract(
const extracted = await runtime.extract(
roleDef.schema as z.ZodType<Record<string, unknown>>,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
);
const contentHash = await putContentBlob(runtime.cas, raw);
const refsFromMeta = resolveExtractedRefs(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
meta,
extracted.meta,
);
const refs = refsFromMeta.includes(contentHash) ? refsFromMeta : [...refsFromMeta, contentHash];
const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta);
const contentHash = await putContentNodeWithRefs(
runtime.cas,
extracted.contentPayload,
artifactRefs,
);
const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash];
const step = {
role: next,
contentHash,
meta,
meta: extracted.meta,
refs,
timestamp: Date.now(),
} as RoleStep<M>;
+26 -24
View File
@@ -1,29 +1,31 @@
export { buildThreadContext } from "./build-context.js";
export { createWorkflow } from "./create-workflow.js";
export { err, ok } from "./result.js";
export type {
AgentBinding,
AgentContext,
AgentFn,
CasStore,
ExtractContext,
ExtractFn,
LlmProvider,
Moderator,
ModeratorContext,
Result,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
AgentBinding,
AgentContext,
AgentFn,
CasStore,
ExtractContext,
ExtractFn,
ExtractResult,
LlmProvider,
Moderator,
ModeratorContext,
Result,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
} from "./types.js";
export { END, START } from "./types.js";
+26 -25
View File
@@ -3,31 +3,32 @@
// imports from "@uncaged/workflow-runtime" continues to work.
export type {
AgentBinding,
AgentContext,
AgentFn,
AdvanceOutcome,
CasStore,
ExtractContext,
ExtractFn,
LlmProvider,
Moderator,
ModeratorContext,
Result,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
CasStore,
ExtractContext,
ExtractFn,
ExtractResult,
LlmProvider,
Moderator,
ModeratorContext,
Result,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
} from "@uncaged/workflow-protocol";
export { END, START } from "@uncaged/workflow-protocol";
+1 -3
View File
@@ -18,7 +18,5 @@
"types": ["bun-types"]
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" }
]
"references": [{ "path": "../workflow-cas" }, { "path": "../workflow-protocol" }]
}
@@ -6,8 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-register" },
{ "path": "../workflow-runtime" }
]
"references": [{ "path": "../workflow-register" }, { "path": "../workflow-runtime" }]
}
@@ -5,8 +5,13 @@ import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import { createExtract } from "@uncaged/workflow-execute";
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
import {
createWorkflow,
END,
type ModeratorContext,
type RoleStep,
START,
} from "@uncaged/workflow-runtime";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import type { DeveloperMeta } from "../src/developer.js";
import { solveIssueModerator, solveIssueWorkflowDefinition } from "../src/index.js";
@@ -6,8 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-register" },
{ "path": "../workflow-runtime" }
]
"references": [{ "path": "../workflow-register" }, { "path": "../workflow-runtime" }]
}
+6 -6
View File
@@ -1,13 +1,13 @@
export { err, ok } from "@uncaged/workflow-protocol";
export {
CROCKFORD_BASE32_ALPHABET,
decodeCrockfordBase32Bits,
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
CROCKFORD_BASE32_ALPHABET,
decodeCrockfordBase32Bits,
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "./base32.js";
export { createLogger } from "./logger.js";
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
export { ok, err } from "@uncaged/workflow-protocol";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
export type { CreateLoggerOptions, LogFn, LoggerSink, Result } from "./types.js";
export { generateUlid } from "./ulid.js";
+1 -1
View File
@@ -3,7 +3,7 @@ export type { Result } from "@uncaged/workflow-protocol";
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
export type CreateLoggerOptions = {
sink: LoggerSink;
sink: LoggerSink;
};
export type LogFn = (tag: string, content: string) => void;
+1 -3
View File
@@ -5,7 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" }
]
"references": [{ "path": "../workflow-protocol" }]
}