refactor(workflow): split @uncaged/workflow-runtime from engine (Phase 1)

Create packages/workflow-runtime with the minimal runtime subset:
- Types (WorkflowFn, RoleOutput, AgentBinding, etc.)
- createWorkflow (pure orchestration, zero I/O)
- validateWorkflowDescriptor
- Result/ok/err, START/END constants

Zero external dependencies (zod as peer only).
Zero node:fs/node:path imports.

Engine (@uncaged/workflow) now depends on workflow-runtime and
provides CAS/merkle/extract implementations via injection.

Refs #121, relates #122
This commit is contained in:
2026-05-08 06:29:49 +00:00
parent 7e662f9287
commit 495c000356
49 changed files with 536 additions and 324 deletions
@@ -3,7 +3,13 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
import {
createContentMerkleNode,
getGlobalCasDir,
getRegisteredWorkflow,
readWorkflowRegistry,
serializeMerkleNode,
} from "@uncaged/workflow";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
import {
cmdAdd,
@@ -22,6 +28,10 @@ const fixtureDescriptor = `export const descriptor = { description: "fixture", r
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`;
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
describe("cli workflow commands", () => {
let prevEnv: string | undefined;
let storageRoot: string;
@@ -402,21 +412,23 @@ export const run = async function* (input, options) {
});
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
const put = await cmdCasPut(storageRoot, "phase doc");
const raw = "phase doc";
const stored = casStoredForm(raw);
const put = await cmdCasPut(storageRoot, raw);
expect(put.ok).toBe(true);
if (!put.ok) {
return;
}
const hash = put.value;
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
expect(await readFile(blobPath, "utf8")).toBe(stored);
const got = await cmdCasGet(storageRoot, hash);
expect(got.ok).toBe(true);
if (!got.ok) {
return;
}
expect(got.value).toBe("phase doc");
expect(got.value).toBe(stored);
const listed = await cmdCasList(storageRoot);
expect(listed.ok).toBe(true);
@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test";
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow";
import { createApp } from "../src/commands/serve/app.js";
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
function buildApp(storageRoot: string) {
const app = createApp(storageRoot);
return {
@@ -89,7 +95,7 @@ describe("serve CAS round-trip", () => {
const getRes = await fetch(`/api/cas/${putBody.hash}`);
expect(getRes.status).toBe(200);
const getBody = (await getRes.json()) as { content: string };
expect(getBody.content).toBe("hello world");
expect(getBody.content).toBe(casStoredForm("hello world"));
// cleanup
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.2.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0"
}
}
@@ -0,0 +1,2 @@
export type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
@@ -0,0 +1,13 @@
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
};
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
@@ -0,0 +1,40 @@
import { err, ok, type Result } from "../util/index.js";
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
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");
}
const root = value as Record<string, unknown>;
const description = root.description;
if (typeof description !== "string") {
return err("descriptor.description must be a string");
}
const rolesRaw = root.roles;
if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
return err("descriptor.roles must be a non-array object");
}
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 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`);
}
roles[roleName] = {
description: roleDesc,
schema: schema as WorkflowRoleSchema,
};
}
return ok({ description, roles });
}
@@ -0,0 +1 @@
export type { CasStore } from "./types.js";
@@ -0,0 +1,6 @@
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
@@ -0,0 +1,185 @@
import type { CasStore } from "../cas/index.js";
import {
type AgentBinding,
type AgentContext,
type AgentFn,
END,
type ExtractContext,
type ModeratorContext,
type ResolveRoleMetaFn,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type ThreadInput,
type WorkflowCompletion,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowFnOptions,
} from "../types.js";
import { mergeRefsWithContentHash } from "../util/index.js";
function isRoleNext<M extends RoleMeta>(
next: (keyof M & string) | typeof END,
): next is keyof M & string {
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>);
}
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
return store.put(raw);
}
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
const overrides = binding.overrides;
const overrideFn: AgentFn | undefined =
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
return overrideFn !== undefined ? overrideFn : binding.agent;
}
type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
async function advanceOneRound<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
resolveRoleMeta: ResolveRoleMetaFn<M>,
params: {
start: ModeratorContext<M>["start"];
steps: RoleStep<M>[];
options: WorkflowFnOptions;
},
): Promise<AdvanceOutcome<M>> {
const { start, steps, options } = params;
const modCtx: ModeratorContext<M> = {
threadId: options.threadId,
depth: options.depth,
start,
steps,
};
const next = def.moderator(modCtx);
if (!isRoleNext(next)) {
return {
kind: "complete",
completion: { returnCode: 0, summary: "completed: moderator returned END" },
};
}
const roleDef = def.roles[next];
if (roleDef === undefined) {
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
}
const agentCtx: AgentContext<M> = {
...modCtx,
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
cas: options.cas,
};
const agent = agentForRole(binding, next);
const raw = await agent(agentCtx as unknown as AgentContext);
const extractCtx: ExtractContext<M> = {
...agentCtx,
agentContent: raw,
};
const meta = await resolveRoleMeta(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
extractCtx,
options,
);
const contentHash = await putContentBlob(options.cas, raw);
const refs = mergeRefsWithContentHash(
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
contentHash,
);
const step = {
role: next,
contentHash,
meta,
refs,
timestamp: Date.now(),
} as RoleStep<M>;
return {
kind: "yield",
output: {
role: step.role,
contentHash: step.contentHash,
meta: step.meta,
refs: step.refs,
},
step,
};
}
/**
* Binds pure role definitions + moderator to runtime agents.
* Assign with `export const run = createWorkflow(def, binding)` via `@uncaged/workflow`,
* which supplies {@link ResolveRoleMetaFn}.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
resolveRoleMeta: ResolveRoleMetaFn<M>,
): WorkflowFn {
return async function* workflowLoop(
input: ThreadInput,
options: WorkflowFnOptions,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const nowMs = Date.now();
const start: ModeratorContext<M>["start"] = {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
timestamp: nowMs,
};
const baseTs = Date.now();
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
role: out.role,
contentHash: out.contentHash,
meta: out.meta,
refs: out.refs,
timestamp: baseTs + i,
})) as RoleStep<M>[];
while (true) {
if (steps.length >= options.maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${options.maxRounds})`,
};
}
const outcome = await advanceOneRound(def, binding, resolveRoleMeta, {
start,
steps,
options,
});
if (outcome.kind === "complete") {
return outcome.completion;
}
yield outcome.output;
steps = [...steps, outcome.step];
}
};
}
@@ -0,0 +1 @@
export { createWorkflow } from "./create-workflow.js";
@@ -0,0 +1 @@
export type { ExtractFn } from "./types.js";
@@ -0,0 +1,9 @@
import type * as z from "zod/v4";
import type { ExtractContext } from "../types.js";
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<T>;
+35
View File
@@ -0,0 +1,35 @@
export type {
WorkflowDescriptor,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./bundle/types.js";
export { validateWorkflowDescriptor } from "./bundle/workflow-descriptor.js";
export type { CasStore } from "./cas/index.js";
export { createWorkflow } from "./engine/index.js";
export type { ExtractFn } from "./extract/index.js";
export type {
AgentBinding,
AgentContext,
AgentFn,
ExtractContext,
ExtractMode,
LlmProvider,
Moderator,
ModeratorContext,
ResolveRoleMetaFn,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
ThreadInput,
WorkflowCompletion,
WorkflowDefinition,
WorkflowFn,
WorkflowFnOptions,
WorkflowResult,
} from "./types.js";
export { END, START } from "./types.js";
export type { Result } from "./util/index.js";
export { err, ok } from "./util/index.js";
@@ -36,7 +36,7 @@ export type WorkflowCompletion = {
summary: string;
};
/** Final thread outcome from {@link executeThread}, including Merkle thread root CAS hash. */
/** Final thread outcome from executeThread, including Merkle thread root CAS hash. */
export type WorkflowResult = WorkflowCompletion & {
rootHash: string;
};
@@ -115,10 +115,10 @@ export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>;
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
export type AgentFn = (ctx: AgentContext) => Promise<string>;
/** Runtime agent assignment (optional per-role overrides). */
/** Runtime agent assignment (explicit null when no per-role overrides). */
export type AgentBinding = {
agent: AgentFn;
overrides?: Partial<Record<string, AgentFn>>;
overrides: Partial<Record<string, AgentFn>> | null;
};
/** Role wiring: prompts, schema, and human-readable description. */
@@ -148,3 +148,10 @@ export type WorkflowDefinition<M extends RoleMeta> = {
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
};
/** Engine-injected meta extraction for workflow loops (single + react modes). */
export type ResolveRoleMetaFn<M extends RoleMeta = RoleMeta> = (
roleDef: RoleDefinition<Record<string, unknown>>,
extractCtx: ExtractContext<M>,
options: WorkflowFnOptions,
) => Promise<Record<string, unknown>>;
@@ -0,0 +1,3 @@
export { mergeRefsWithContentHash } from "./refs-field.js";
export { err, ok } from "./result.js";
export type { Result } from "./types.js";
@@ -0,0 +1,8 @@
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
const out = [...refs];
if (!out.includes(contentHash)) {
out.push(contentHash);
}
return out;
}
@@ -0,0 +1,9 @@
import type { Result } from "./types.js";
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
@@ -0,0 +1 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
+21
View File
@@ -0,0 +1,21 @@
{
"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"]
}
@@ -1,9 +1,8 @@
import { describe, expect, test } from "bun:test";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
import { END } from "../src/types.js";
describe("buildDescriptor", () => {
test("produces a descriptor that validates and includes JSON schemas per role", () => {
+21 -9
View File
@@ -5,6 +5,11 @@ import { join } from "node:path";
import { createCasStore } from "../src/cas/cas.js";
import { hashString } from "../src/cas/hash.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
describe("createCasStore", () => {
let casDir: string;
@@ -19,25 +24,30 @@ describe("createCasStore", () => {
test("put returns consistent hash for same content", async () => {
const cas = createCasStore(casDir);
const h1 = await cas.put("hello world");
const h2 = await cas.put("hello world");
const raw = "hello world";
const stored = casStoredForm(raw);
const h1 = await cas.put(raw);
const h2 = await cas.put(raw);
expect(h1).toBe(h2);
expect(h1).toBe(hashString(stored));
expect(h1).toHaveLength(13);
});
test("put returns hash matching hashString", async () => {
test("put returns hash matching hashString of merkle-stored form", async () => {
const cas = createCasStore(casDir);
const content = "some content to store";
const stored = casStoredForm(content);
const h = await cas.put(content);
expect(h).toBe(hashString(content));
expect(h).toBe(hashString(stored));
});
test("get returns stored content", async () => {
test("get returns merkle-serialized blob for raw puts", async () => {
const cas = createCasStore(casDir);
const content = "line1\nline2\nline3";
const stored = casStoredForm(content);
const h = await cas.put(content);
const retrieved = await cas.get(h);
expect(retrieved).toBe(content);
expect(retrieved).toBe(stored);
});
test("get returns null for missing hash", async () => {
@@ -76,11 +86,13 @@ describe("createCasStore", () => {
test("put is idempotent — same content written twice causes no error", async () => {
const cas = createCasStore(casDir);
const h1 = await cas.put("idempotent");
const h2 = await cas.put("idempotent");
const raw = "idempotent";
const stored = casStoredForm(raw);
const h1 = await cas.put(raw);
const h2 = await cas.put(raw);
expect(h1).toBe(h2);
const content = await cas.get(h1);
expect(content).toBe("idempotent");
expect(content).toBe(stored);
});
test("different content produces different hashes", async () => {
+2 -3
View File
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js";
import {
createContentMerkleNode,
@@ -13,7 +13,6 @@ import {
} from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const plannerMetaSchema = z.object({
@@ -669,7 +668,7 @@ describe("executeThread", () => {
},
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
},
{ agent: async () => dagRootHash },
{ agent: async () => dagRootHash, overrides: null },
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -2,12 +2,11 @@ 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 type { LlmProvider } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { reactExtract } from "../src/extract/react-extract.js";
import type { LlmProvider } from "../src/types.js";
const metaSchema = z.object({ seen: z.string() });
@@ -2,13 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const phaseSchema = z.object({
@@ -102,6 +101,7 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
},
{
agent: async () => "plan-output",
overrides: null,
},
);
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
@@ -14,7 +14,6 @@ import {
registerWorkflowVersion,
writeWorkflowRegistry,
} from "../src/registry/registry.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js";
@@ -153,7 +152,7 @@ describe("workflowAsAgent integration", () => {
},
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
},
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
{ agent: workflowAsAgent("child-wf", { storageRoot: root }), overrides: null },
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { parseMerkleNode } from "../src/cas/merkle.js";
@@ -11,7 +11,6 @@ import {
registerWorkflowVersion,
writeWorkflowRegistry,
} from "../src/registry/registry.js";
import { type AgentContext, START } from "../src/types.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js";
function makeAgentCtx(params: {
+1
View File
@@ -8,6 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"acorn": "^8.16.0",
"xxhashjs": "^0.2.2",
"yaml": "^2.8.4"
@@ -1,6 +1,5 @@
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import type { RoleMeta, WorkflowDefinition } from "../types.js";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
@@ -1,4 +1,4 @@
import type { WorkflowFn } from "../types.js";
import type { WorkflowFn } from "@uncaged/workflow-runtime";
import { err, ok, type Result } from "../util/index.js";
import { importWorkflowBundleModule } from "./bundle-import-env.js";
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
+6 -14
View File
@@ -1,18 +1,10 @@
import type { WorkflowFn } from "../types.js";
import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-runtime";
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
};
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
export type {
WorkflowDescriptor,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "@uncaged/workflow-runtime";
export type WorkflowBundleValidationInput = {
/** Absolute or relative path (used for `.esm.js` suffix checks). */
@@ -1,40 +1 @@
import { err, ok, type Result } from "../util/index.js";
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
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");
}
const root = value as Record<string, unknown>;
const description = root.description;
if (typeof description !== "string") {
return err("descriptor.description must be a string");
}
const rolesRaw = root.roles;
if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
return err("descriptor.roles must be a non-array object");
}
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 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`);
}
roles[roleName] = {
description: roleDesc,
schema: schema as WorkflowRoleSchema,
};
}
return ok({ description, roles });
}
export { validateWorkflowDescriptor } from "@uncaged/workflow-runtime";
+14 -2
View File
@@ -2,8 +2,19 @@ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/pro
import { join } from "node:path";
import { hashString } from "./hash.js";
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "./merkle.js";
import type { CasStore } from "./types.js";
/** Raw strings become content merkle YAML; already-valid merkle documents pass through. */
function normalizeCasPutContent(content: string): string {
try {
parseMerkleNode(content);
return content;
} catch {
return serializeMerkleNode(createContentMerkleNode(content));
}
}
export function createCasStore(casDir: string): CasStore {
async function ensureDir(): Promise<void> {
await mkdir(casDir, { recursive: true });
@@ -15,11 +26,12 @@ export function createCasStore(casDir: string): CasStore {
return {
async put(content: string): Promise<string> {
const hash = hashString(content);
const toStore = normalizeCasPutContent(content);
const hash = hashString(toStore);
await ensureDir();
const target = filePath(hash);
const tmp = `${target}.tmp.${Date.now()}`;
await writeFile(tmp, content, "utf8");
await writeFile(tmp, toStore, "utf8");
await rename(tmp, target);
return hash;
},
+2 -3
View File
@@ -77,10 +77,9 @@ export async function putThreadMerkleNode(
return store.put(serializeMerkleNode(node));
}
/** Serializes a content Merkle node and stores it in CAS; returns its hash. */
/** Stores agent/content text via CAS; {@link createCasStore} wraps raw strings as merkle content nodes. */
export async function putContentMerkleNode(store: CasStore, content: string): Promise<string> {
const yamlText = serializeMerkleNode(createContentMerkleNode(content));
return store.put(yamlText);
return store.put(content);
}
/** Loads a CAS blob and returns the payload string for a `content` Merkle node. */
+1 -6
View File
@@ -1,9 +1,4 @@
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
export type { CasStore } from "@uncaged/workflow-runtime";
export type MerkleNodeType = "content" | "step" | "thread";
+9 -163
View File
@@ -1,73 +1,12 @@
import { putContentMerkleNode } from "../cas/index.js";
import { buildExtractUserContent, reactExtract } from "../extract/index.js";
import {
type AgentBinding,
type AgentContext,
END,
type ExtractContext,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type ThreadInput,
type WorkflowCompletion,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowFnOptions,
} from "../types.js";
import { mergeRefsWithContentHash } from "../util/index.js";
import type {
AgentBinding,
RoleMeta,
WorkflowDefinition,
WorkflowFn,
} from "@uncaged/workflow-runtime";
import { createWorkflow as createWorkflowRuntime } from "@uncaged/workflow-runtime";
function isRoleNext<M extends RoleMeta>(
next: (keyof M & string) | typeof END,
): next is keyof M & string {
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>);
}
async function resolveRoleMeta<M extends RoleMeta>(
roleDef: RoleDefinition<Record<string, unknown>>,
extractCtx: ExtractContext<M>,
options: WorkflowFnOptions,
): Promise<Record<string, unknown>> {
if (roleDef.extractMode === "react") {
if (options.llmProvider === null) {
throw new Error(
'createWorkflow: WorkflowFnOptions.llmProvider is required when a role uses extractMode "react"',
);
}
const text = await buildExtractUserContent(
extractCtx as unknown as ExtractContext,
roleDef.extractPrompt,
);
const reactResult = await reactExtract({
text,
schema: roleDef.schema,
provider: options.llmProvider,
cas: options.cas,
});
if (!reactResult.ok) {
throw new Error(`react extract failed: ${reactResult.error}`);
}
return reactResult.value as Record<string, unknown>;
}
return (await options.extract(
roleDef.schema,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
)) as Record<string, unknown>;
}
import { resolveRoleMeta } from "./resolve-role-meta.js";
/**
* Binds pure role definitions + moderator to runtime agents.
@@ -78,98 +17,5 @@ export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
): WorkflowFn {
return async function* workflowLoop(
input: ThreadInput,
options: WorkflowFnOptions,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const nowMs = Date.now();
const start: ModeratorContext<M>["start"] = {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
timestamp: nowMs,
};
const baseTs = Date.now();
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
role: out.role,
contentHash: out.contentHash,
meta: out.meta,
refs: out.refs,
timestamp: baseTs + i,
})) as RoleStep<M>[];
while (true) {
if (steps.length >= options.maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${options.maxRounds})`,
};
}
const modCtx: ModeratorContext<M> = {
threadId: options.threadId,
depth: options.depth,
start,
steps,
};
const next = def.moderator(modCtx);
if (!isRoleNext(next)) {
return { returnCode: 0, summary: "completed: moderator returned END" };
}
const roleDef = def.roles[next];
if (roleDef === undefined) {
return { returnCode: 1, summary: `unknown role: ${next}` };
}
const agentCtx: AgentContext<M> = {
...modCtx,
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
cas: options.cas,
};
const agent = binding.overrides?.[next] ?? binding.agent;
const raw = await agent(agentCtx as unknown as AgentContext);
const extractCtx: ExtractContext<M> = {
...agentCtx,
agentContent: raw,
};
const meta = await resolveRoleMeta(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
extractCtx,
options,
);
const contentHash = await putContentMerkleNode(options.cas, raw);
const refs = mergeRefsWithContentHash(
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
contentHash,
);
const ts = Date.now();
const step = {
role: next,
contentHash,
meta,
refs,
timestamp: ts,
} as RoleStep<M>;
yield {
role: step.role,
contentHash: step.contentHash,
meta: step.meta,
refs: step.refs,
};
steps = [...steps, step];
}
};
return createWorkflowRuntime(def, binding, resolveRoleMeta);
}
+8 -9
View File
@@ -1,6 +1,13 @@
import { appendFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import type {
LlmProvider,
ThreadInput,
WorkflowCompletion,
WorkflowFn,
WorkflowFnOptions,
WorkflowResult,
} from "@uncaged/workflow-runtime";
import {
type CasStore,
getContentMerklePayload,
@@ -10,14 +17,6 @@ import {
import { resolveModel } from "../config/index.js";
import { createExtract } from "../extract/index.js";
import { readWorkflowRegistry, type WorkflowConfig } from "../registry/index.js";
import type {
LlmProvider,
ThreadInput,
WorkflowCompletion,
WorkflowFn,
WorkflowFnOptions,
WorkflowResult,
} from "../types.js";
import { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/index.js";
import { runSupervisor } from "./supervisor.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { WorkflowCompletion } from "../types.js";
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
import { err, normalizeRefsField, ok, type Result } from "../util/index.js";
import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.js";
@@ -0,0 +1,42 @@
import type {
ExtractContext,
RoleDefinition,
RoleMeta,
WorkflowFnOptions,
} from "@uncaged/workflow-runtime";
import { buildExtractUserContent } from "../extract/extract-fn.js";
import { reactExtract } from "../extract/react-extract.js";
export async function resolveRoleMeta<M extends RoleMeta>(
roleDef: RoleDefinition<Record<string, unknown>>,
extractCtx: ExtractContext<M>,
options: WorkflowFnOptions,
): Promise<Record<string, unknown>> {
if (roleDef.extractMode === "react") {
if (options.llmProvider === null) {
throw new Error(
'createWorkflow: WorkflowFnOptions.llmProvider is required when a role uses extractMode "react"',
);
}
const text = await buildExtractUserContent(
extractCtx as unknown as ExtractContext,
roleDef.extractPrompt,
);
const reactResult = await reactExtract({
text,
schema: roleDef.schema,
provider: options.llmProvider,
cas: options.cas,
});
if (!reactResult.ok) {
throw new Error(`react extract failed: ${reactResult.error}`);
}
return reactResult.value as Record<string, unknown>;
}
return (await options.extract(
roleDef.schema,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
)) as Record<string, unknown>;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { RoleOutput } from "@uncaged/workflow-runtime";
import type { CasStore } from "../cas/index.js";
import type { RoleOutput } from "../types.js";
import type { Result } from "../util/index.js";
export type SupervisorDecision = "continue" | "stop";
+1 -1
View File
@@ -1,9 +1,9 @@
import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type Socket } from "node:net";
import { dirname, join } from "node:path";
import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime";
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "../bundle/index.js";
import { createCasStore } from "../cas/index.js";
import type { RoleOutput, WorkflowFn, WorkflowResult } from "../types.js";
import {
createLogger,
err,
+1 -3
View File
@@ -1,9 +1,7 @@
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { getContentMerklePayload } from "../cas/index.js";
import type { ExtractContext, LlmProvider } from "../types.js";
import { llmExtractWithRetry } from "./llm-extract.js";
import type { ExtractFn } from "./types.js";
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
export async function buildExtractUserContent(
@@ -1,7 +1,5 @@
import type { CasStore, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import type { CasStore } from "../cas/index.js";
import type { LlmProvider } from "../types.js";
import { err, ok, type Result } from "../util/index.js";
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
+2 -8
View File
@@ -1,13 +1,7 @@
import type { CasStore, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import type { CasStore } from "../cas/index.js";
import type { ExtractContext, LlmProvider } from "../types.js";
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<T>;
export type { ExtractFn } from "@uncaged/workflow-runtime";
export type ReactExtractArgs<T extends Record<string, unknown>> = {
text: string;
+24 -24
View File
@@ -1,3 +1,27 @@
export {
type AgentBinding,
type AgentContext,
type AgentFn,
END,
type ExtractContext,
type ExtractMode,
type LlmProvider,
type Moderator,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type StartStep,
type ThreadContext,
type ThreadInput,
type WorkflowCompletion,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowFnOptions,
type WorkflowResult,
} from "@uncaged/workflow-runtime";
export {
buildDescriptor,
type ExtractedBundleExports,
@@ -79,30 +103,6 @@ export {
workflowRegistryPath,
writeWorkflowRegistry,
} from "./registry/index.js";
export {
type AgentBinding,
type AgentContext,
type AgentFn,
END,
type ExtractContext,
type ExtractMode,
type LlmProvider,
type Moderator,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type StartStep,
type ThreadContext,
type ThreadInput,
type WorkflowCompletion,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowFnOptions,
type WorkflowResult,
} from "./types.js";
export {
CROCKFORD_BASE32_ALPHABET,
type CreateLoggerOptions,
+1 -9
View File
@@ -1,9 +1 @@
import type { Result } from "./types.js";
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
export { err, ok } from "@uncaged/workflow-runtime";
+1 -1
View File
@@ -1,4 +1,4 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
export type { Result } from "@uncaged/workflow-runtime";
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
+1 -2
View File
@@ -1,12 +1,11 @@
import { join } from "node:path";
import type { AgentContext, AgentFn, ThreadInput } from "@uncaged/workflow-runtime";
import { extractBundleExports } from "./bundle/index.js";
import { createCasStore } from "./cas/index.js";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread } from "./engine/index.js";
import type { WorkflowConfig } from "./registry/index.js";
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry/index.js";
import type { AgentContext, AgentFn, ThreadInput } from "./types.js";
import {
createLogger,
generateUlid,
+1
View File
@@ -1,4 +1,5 @@
{
"references": [{ "path": "../workflow-runtime" }],
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
+1
View File
@@ -17,6 +17,7 @@
"types": ["bun-types"]
},
"references": [
{ "path": "packages/workflow-runtime" },
{ "path": "packages/workflow" },
{ "path": "packages/workflow-agent-llm" },
{ "path": "packages/workflow-agent-cursor" },