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:
@@ -3,7 +3,13 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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 { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||||
import {
|
import {
|
||||||
cmdAdd,
|
cmdAdd,
|
||||||
@@ -22,6 +28,10 @@ const fixtureDescriptor = `export const descriptor = { description: "fixture", r
|
|||||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
function casStoredForm(raw: string): string {
|
||||||
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
|
}
|
||||||
|
|
||||||
describe("cli workflow commands", () => {
|
describe("cli workflow commands", () => {
|
||||||
let prevEnv: string | undefined;
|
let prevEnv: string | undefined;
|
||||||
let storageRoot: string;
|
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 () => {
|
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);
|
expect(put.ok).toBe(true);
|
||||||
if (!put.ok) {
|
if (!put.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hash = put.value;
|
const hash = put.value;
|
||||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
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);
|
const got = await cmdCasGet(storageRoot, hash);
|
||||||
expect(got.ok).toBe(true);
|
expect(got.ok).toBe(true);
|
||||||
if (!got.ok) {
|
if (!got.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(got.value).toBe("phase doc");
|
expect(got.value).toBe(stored);
|
||||||
|
|
||||||
const listed = await cmdCasList(storageRoot);
|
const listed = await cmdCasList(storageRoot);
|
||||||
expect(listed.ok).toBe(true);
|
expect(listed.ok).toBe(true);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow";
|
||||||
|
|
||||||
import { createApp } from "../src/commands/serve/app.js";
|
import { createApp } from "../src/commands/serve/app.js";
|
||||||
|
|
||||||
|
function casStoredForm(raw: string): string {
|
||||||
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
|
}
|
||||||
|
|
||||||
function buildApp(storageRoot: string) {
|
function buildApp(storageRoot: string) {
|
||||||
const app = createApp(storageRoot);
|
const app = createApp(storageRoot);
|
||||||
return {
|
return {
|
||||||
@@ -89,7 +95,7 @@ describe("serve CAS round-trip", () => {
|
|||||||
const getRes = await fetch(`/api/cas/${putBody.hash}`);
|
const getRes = await fetch(`/api/cas/${putBody.hash}`);
|
||||||
expect(getRes.status).toBe(200);
|
expect(getRes.status).toBe(200);
|
||||||
const getBody = (await getRes.json()) as { content: string };
|
const getBody = (await getRes.json()) as { content: string };
|
||||||
expect(getBody.content).toBe("hello world");
|
expect(getBody.content).toBe(casStoredForm("hello world"));
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
|
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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;
|
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 & {
|
export type WorkflowResult = WorkflowCompletion & {
|
||||||
rootHash: string;
|
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. */
|
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
||||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
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 = {
|
export type AgentBinding = {
|
||||||
agent: AgentFn;
|
agent: AgentFn;
|
||||||
overrides?: Partial<Record<string, AgentFn>>;
|
overrides: Partial<Record<string, AgentFn>> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Role wiring: prompts, schema, and human-readable description. */
|
/** 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]> };
|
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||||
moderator: Moderator<M>;
|
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 };
|
||||||
@@ -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 { describe, expect, test } from "bun:test";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
||||||
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
|
|
||||||
describe("buildDescriptor", () => {
|
describe("buildDescriptor", () => {
|
||||||
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { hashString } from "../src/cas/hash.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", () => {
|
describe("createCasStore", () => {
|
||||||
let casDir: string;
|
let casDir: string;
|
||||||
@@ -19,25 +24,30 @@ describe("createCasStore", () => {
|
|||||||
|
|
||||||
test("put returns consistent hash for same content", async () => {
|
test("put returns consistent hash for same content", async () => {
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
const h1 = await cas.put("hello world");
|
const raw = "hello world";
|
||||||
const h2 = await cas.put("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(h2);
|
||||||
|
expect(h1).toBe(hashString(stored));
|
||||||
expect(h1).toHaveLength(13);
|
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 cas = createCasStore(casDir);
|
||||||
const content = "some content to store";
|
const content = "some content to store";
|
||||||
|
const stored = casStoredForm(content);
|
||||||
const h = await cas.put(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 cas = createCasStore(casDir);
|
||||||
const content = "line1\nline2\nline3";
|
const content = "line1\nline2\nline3";
|
||||||
|
const stored = casStoredForm(content);
|
||||||
const h = await cas.put(content);
|
const h = await cas.put(content);
|
||||||
const retrieved = await cas.get(h);
|
const retrieved = await cas.get(h);
|
||||||
expect(retrieved).toBe(content);
|
expect(retrieved).toBe(stored);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("get returns null for missing hash", async () => {
|
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 () => {
|
test("put is idempotent — same content written twice causes no error", async () => {
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
const h1 = await cas.put("idempotent");
|
const raw = "idempotent";
|
||||||
const h2 = await cas.put("idempotent");
|
const stored = casStoredForm(raw);
|
||||||
|
const h1 = await cas.put(raw);
|
||||||
|
const h2 = await cas.put(raw);
|
||||||
expect(h1).toBe(h2);
|
expect(h1).toBe(h2);
|
||||||
const content = await cas.get(h1);
|
const content = await cas.get(h1);
|
||||||
expect(content).toBe("idempotent");
|
expect(content).toBe(stored);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("different content produces different hashes", async () => {
|
test("different content produces different hashes", async () => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import {
|
import {
|
||||||
createContentMerkleNode,
|
createContentMerkleNode,
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "../src/cas/merkle.js";
|
} from "../src/cas/merkle.js";
|
||||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||||
import { executeThread } from "../src/engine/engine.js";
|
import { executeThread } from "../src/engine/engine.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
import { createLogger } from "../src/util/logger.js";
|
import { createLogger } from "../src/util/logger.js";
|
||||||
|
|
||||||
const plannerMetaSchema = z.object({
|
const plannerMetaSchema = z.object({
|
||||||
@@ -669,7 +668,7 @@ describe("executeThread", () => {
|
|||||||
},
|
},
|
||||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||||
},
|
},
|
||||||
{ agent: async () => dagRootHash },
|
{ agent: async () => dagRootHash, overrides: null },
|
||||||
);
|
);
|
||||||
|
|
||||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||||
import { reactExtract } from "../src/extract/react-extract.js";
|
import { reactExtract } from "../src/extract/react-extract.js";
|
||||||
import type { LlmProvider } from "../src/types.js";
|
|
||||||
|
|
||||||
const metaSchema = z.object({ seen: z.string() });
|
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 { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||||
import { executeThread } from "../src/engine/engine.js";
|
import { executeThread } from "../src/engine/engine.js";
|
||||||
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
|
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
import { createLogger } from "../src/util/logger.js";
|
import { createLogger } from "../src/util/logger.js";
|
||||||
|
|
||||||
const phaseSchema = z.object({
|
const phaseSchema = z.object({
|
||||||
@@ -102,6 +101,7 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
agent: async () => "plan-output",
|
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 { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||||
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
|
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
registerWorkflowVersion,
|
registerWorkflowVersion,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "../src/registry/registry.js";
|
} from "../src/registry/registry.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
import { createLogger } from "../src/util/logger.js";
|
import { createLogger } from "../src/util/logger.js";
|
||||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||||
|
|
||||||
@@ -153,7 +152,7 @@ describe("workflowAsAgent integration", () => {
|
|||||||
},
|
},
|
||||||
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
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";
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||||
import { parseMerkleNode } from "../src/cas/merkle.js";
|
import { parseMerkleNode } from "../src/cas/merkle.js";
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
registerWorkflowVersion,
|
registerWorkflowVersion,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "../src/registry/registry.js";
|
} from "../src/registry/registry.js";
|
||||||
import { type AgentContext, START } from "../src/types.js";
|
|
||||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||||
|
|
||||||
function makeAgentCtx(params: {
|
function makeAgentCtx(params: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"acorn": "^8.16.0",
|
"acorn": "^8.16.0",
|
||||||
"xxhashjs": "^0.2.2",
|
"xxhashjs": "^0.2.2",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import type { RoleMeta, WorkflowDefinition } from "../types.js";
|
|
||||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||||
|
|
||||||
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
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 { err, ok, type Result } from "../util/index.js";
|
||||||
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||||
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||||
|
|||||||
@@ -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 {
|
||||||
export type WorkflowRoleSchema = Record<string, unknown>;
|
WorkflowDescriptor,
|
||||||
|
WorkflowRoleDescriptor,
|
||||||
export type WorkflowRoleDescriptor = {
|
WorkflowRoleSchema,
|
||||||
description: string;
|
} from "@uncaged/workflow-runtime";
|
||||||
schema: WorkflowRoleSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
|
|
||||||
export type WorkflowDescriptor = {
|
|
||||||
description: string;
|
|
||||||
roles: Record<string, WorkflowRoleDescriptor>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowBundleValidationInput = {
|
export type WorkflowBundleValidationInput = {
|
||||||
/** Absolute or relative path (used for `.esm.js` suffix checks). */
|
/** Absolute or relative path (used for `.esm.js` suffix checks). */
|
||||||
|
|||||||
@@ -1,40 +1 @@
|
|||||||
import { err, ok, type Result } from "../util/index.js";
|
export { validateWorkflowDescriptor } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,8 +2,19 @@ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/pro
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { hashString } from "./hash.js";
|
import { hashString } from "./hash.js";
|
||||||
|
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "./merkle.js";
|
||||||
import type { CasStore } from "./types.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 {
|
export function createCasStore(casDir: string): CasStore {
|
||||||
async function ensureDir(): Promise<void> {
|
async function ensureDir(): Promise<void> {
|
||||||
await mkdir(casDir, { recursive: true });
|
await mkdir(casDir, { recursive: true });
|
||||||
@@ -15,11 +26,12 @@ export function createCasStore(casDir: string): CasStore {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
async put(content: string): Promise<string> {
|
async put(content: string): Promise<string> {
|
||||||
const hash = hashString(content);
|
const toStore = normalizeCasPutContent(content);
|
||||||
|
const hash = hashString(toStore);
|
||||||
await ensureDir();
|
await ensureDir();
|
||||||
const target = filePath(hash);
|
const target = filePath(hash);
|
||||||
const tmp = `${target}.tmp.${Date.now()}`;
|
const tmp = `${target}.tmp.${Date.now()}`;
|
||||||
await writeFile(tmp, content, "utf8");
|
await writeFile(tmp, toStore, "utf8");
|
||||||
await rename(tmp, target);
|
await rename(tmp, target);
|
||||||
return hash;
|
return hash;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,10 +77,9 @@ export async function putThreadMerkleNode(
|
|||||||
return store.put(serializeMerkleNode(node));
|
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> {
|
export async function putContentMerkleNode(store: CasStore, content: string): Promise<string> {
|
||||||
const yamlText = serializeMerkleNode(createContentMerkleNode(content));
|
return store.put(content);
|
||||||
return store.put(yamlText);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads a CAS blob and returns the payload string for a `content` Merkle node. */
|
/** Loads a CAS blob and returns the payload string for a `content` Merkle node. */
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
export type CasStore = {
|
export type { CasStore } from "@uncaged/workflow-runtime";
|
||||||
put(content: string): Promise<string>;
|
|
||||||
get(hash: string): Promise<string | null>;
|
|
||||||
delete(hash: string): Promise<void>;
|
|
||||||
list(): Promise<string[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MerkleNodeType = "content" | "step" | "thread";
|
export type MerkleNodeType = "content" | "step" | "thread";
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,12 @@
|
|||||||
import { putContentMerkleNode } from "../cas/index.js";
|
import type {
|
||||||
import { buildExtractUserContent, reactExtract } from "../extract/index.js";
|
AgentBinding,
|
||||||
import {
|
RoleMeta,
|
||||||
type AgentBinding,
|
WorkflowDefinition,
|
||||||
type AgentContext,
|
WorkflowFn,
|
||||||
END,
|
} from "@uncaged/workflow-runtime";
|
||||||
type ExtractContext,
|
import { createWorkflow as createWorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
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";
|
|
||||||
|
|
||||||
function isRoleNext<M extends RoleMeta>(
|
import { resolveRoleMeta } from "./resolve-role-meta.js";
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binds pure role definitions + moderator to runtime agents.
|
* Binds pure role definitions + moderator to runtime agents.
|
||||||
@@ -78,98 +17,5 @@ export function createWorkflow<M extends RoleMeta>(
|
|||||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||||
binding: AgentBinding,
|
binding: AgentBinding,
|
||||||
): WorkflowFn {
|
): WorkflowFn {
|
||||||
return async function* workflowLoop(
|
return createWorkflowRuntime(def, binding, resolveRoleMeta);
|
||||||
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];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { appendFile, mkdir } from "node:fs/promises";
|
import { appendFile, mkdir } from "node:fs/promises";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
import type {
|
||||||
|
LlmProvider,
|
||||||
|
ThreadInput,
|
||||||
|
WorkflowCompletion,
|
||||||
|
WorkflowFn,
|
||||||
|
WorkflowFnOptions,
|
||||||
|
WorkflowResult,
|
||||||
|
} from "@uncaged/workflow-runtime";
|
||||||
import {
|
import {
|
||||||
type CasStore,
|
type CasStore,
|
||||||
getContentMerklePayload,
|
getContentMerklePayload,
|
||||||
@@ -10,14 +17,6 @@ import {
|
|||||||
import { resolveModel } from "../config/index.js";
|
import { resolveModel } from "../config/index.js";
|
||||||
import { createExtract } from "../extract/index.js";
|
import { createExtract } from "../extract/index.js";
|
||||||
import { readWorkflowRegistry, type WorkflowConfig } from "../registry/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 { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/index.js";
|
||||||
|
|
||||||
import { runSupervisor } from "./supervisor.js";
|
import { runSupervisor } from "./supervisor.js";
|
||||||
|
|||||||
@@ -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 { err, normalizeRefsField, ok, type Result } from "../util/index.js";
|
||||||
|
|
||||||
import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.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,5 +1,5 @@
|
|||||||
|
import type { RoleOutput } from "@uncaged/workflow-runtime";
|
||||||
import type { CasStore } from "../cas/index.js";
|
import type { CasStore } from "../cas/index.js";
|
||||||
import type { RoleOutput } from "../types.js";
|
|
||||||
import type { Result } from "../util/index.js";
|
import type { Result } from "../util/index.js";
|
||||||
|
|
||||||
export type SupervisorDecision = "continue" | "stop";
|
export type SupervisorDecision = "continue" | "stop";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
|
import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
|
||||||
import { createServer, type Socket } from "node:net";
|
import { createServer, type Socket } from "node:net";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime";
|
||||||
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "../bundle/index.js";
|
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "../bundle/index.js";
|
||||||
import { createCasStore } from "../cas/index.js";
|
import { createCasStore } from "../cas/index.js";
|
||||||
import type { RoleOutput, WorkflowFn, WorkflowResult } from "../types.js";
|
|
||||||
import {
|
import {
|
||||||
createLogger,
|
createLogger,
|
||||||
err,
|
err,
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
|
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
|
||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
import { getContentMerklePayload } from "../cas/index.js";
|
import { getContentMerklePayload } from "../cas/index.js";
|
||||||
import type { ExtractContext, LlmProvider } from "../types.js";
|
|
||||||
import { llmExtractWithRetry } from "./llm-extract.js";
|
import { llmExtractWithRetry } from "./llm-extract.js";
|
||||||
import type { ExtractFn } from "./types.js";
|
|
||||||
|
|
||||||
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
|
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
|
||||||
export async function buildExtractUserContent(
|
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 * 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 { err, ok, type Result } from "../util/index.js";
|
||||||
|
|
||||||
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
|
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
|
import type { CasStore, LlmProvider } from "@uncaged/workflow-runtime";
|
||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
import type { CasStore } from "../cas/index.js";
|
export type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||||
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 ReactExtractArgs<T extends Record<string, unknown>> = {
|
export type ReactExtractArgs<T extends Record<string, unknown>> = {
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -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 {
|
export {
|
||||||
buildDescriptor,
|
buildDescriptor,
|
||||||
type ExtractedBundleExports,
|
type ExtractedBundleExports,
|
||||||
@@ -79,30 +103,6 @@ export {
|
|||||||
workflowRegistryPath,
|
workflowRegistryPath,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "./registry/index.js";
|
} 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 {
|
export {
|
||||||
CROCKFORD_BASE32_ALPHABET,
|
CROCKFORD_BASE32_ALPHABET,
|
||||||
type CreateLoggerOptions,
|
type CreateLoggerOptions,
|
||||||
|
|||||||
@@ -1,9 +1 @@
|
|||||||
import type { Result } from "./types.js";
|
export { err, ok } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 };
|
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { AgentContext, AgentFn, ThreadInput } from "@uncaged/workflow-runtime";
|
||||||
import { extractBundleExports } from "./bundle/index.js";
|
import { extractBundleExports } from "./bundle/index.js";
|
||||||
import { createCasStore } from "./cas/index.js";
|
import { createCasStore } from "./cas/index.js";
|
||||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||||
import { executeThread } from "./engine/index.js";
|
import { executeThread } from "./engine/index.js";
|
||||||
import type { WorkflowConfig } from "./registry/index.js";
|
import type { WorkflowConfig } from "./registry/index.js";
|
||||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry/index.js";
|
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry/index.js";
|
||||||
import type { AgentContext, AgentFn, ThreadInput } from "./types.js";
|
|
||||||
import {
|
import {
|
||||||
createLogger,
|
createLogger,
|
||||||
generateUlid,
|
generateUlid,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"references": [{ "path": "../workflow-runtime" }],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "packages/workflow-runtime" },
|
||||||
{ "path": "packages/workflow" },
|
{ "path": "packages/workflow" },
|
||||||
{ "path": "packages/workflow-agent-llm" },
|
{ "path": "packages/workflow-agent-llm" },
|
||||||
{ "path": "packages/workflow-agent-cursor" },
|
{ "path": "packages/workflow-agent-cursor" },
|
||||||
|
|||||||
Reference in New Issue
Block a user