Compare commits

..

12 Commits

Author SHA1 Message Date
xiaoju a8c1c158d6 feat: engine injects extract provider at runtime (Phase 2)
- createWorkflow(def, binding) — no more extract/llmProvider params
- Engine resolves extract provider from workflow.yaml via resolveModel
- WorkflowFnOptions now carries extract + llmProvider (engine-injected)
- Delete extract-provider.ts, inline maxDepth helper
- Template packages simplified: only take agent binding
- Breaking change: bundles no longer carry provider config

Refs #110
2026-05-08 02:21:45 +00:00
xiaomo 9e6cd9d615 Merge pull request 'feat: unified provider/model configuration (Phase 1)' (#111) from feat/110-phase1-config-layer into main 2026-05-08 02:15:23 +00:00
xiaoju 1f1128ff4a fix: address PR #111 review feedback
- Extract validateWorkspaceSegment to commands/init/validate.ts
- Unify splitProviderModelRef in config/, used by both resolve-model and registry-normalize
- Warn on missing models.default during parse (tag Z2KP9NWQ)
2026-05-08 02:14:20 +00:00
xiaoju aa01283ce1 feat: unified provider/model configuration (Phase 1)
- New src/config/ folder: resolveModel(config, scene) with fallback to default
- WorkflowConfig now has providers + models instead of extract
- Delete ExtractProviderConfig, getExtractProvider uses resolveModel('extract')
- New resolve-model tests, updated existing tests

Refs #110
2026-05-08 02:08:19 +00:00
xiaoju f81e2a8aac Merge pull request 'chore: enforce folder module discipline in @uncaged/cli-workflow' (#109) from chore/108-cli-module-discipline into main 2026-05-08 01:46:03 +00:00
xiaoju 2b38e583be chore: enforce folder module discipline in @uncaged/cli-workflow
Each commands/ subfolder (cas, init, thread, workflow) now has:
- types.ts for all type definitions
- index.ts with pure re-exports only
- External imports go through index.ts

Closes #108
2026-05-08 01:42:32 +00:00
xiaoju 4ff1394224 Merge pull request 'chore: enforce folder module discipline in @uncaged/workflow' (#107) from chore/106-workflow-module-discipline into main 2026-05-08 01:39:48 +00:00
xiaoju 2bbe5a3d0e chore: enforce folder module discipline in @uncaged/workflow
Each folder now has:
- types.ts for all type definitions
- index.ts with pure re-exports only
- Cross-folder imports go through index.ts

Closes #106
2026-05-08 01:37:23 +00:00
xiaoju a4237c0462 docs: add folder module discipline rules to CLAUDE.md
Four rules: index.ts entry point, types.ts for types, single export source,
index.ts is pure re-exports. Also fix stale build command reference.

Refs #102
2026-05-08 01:29:22 +00:00
xiaomo 321e5b1379 Merge pull request 'chore(cli): remove unused <thread-id> from CAS commands' (#105) from chore/cleanup-cas-thread-id into main 2026-05-08 01:25:33 +00:00
xingyue 7c3e14c473 chore(cli): remove unused <thread-id> from CAS commands
CAS is global (not per-thread). The underlying cmdCas* functions
already dropped threadId in #103, but the CLI dispatch layer still
required it from users. Now cleaned up:

- cas get <hash> (was: cas get <thread-id> <hash>)
- cas put <content> (was: cas put <thread-id> <content>)
- cas list (was: cas list <thread-id>)
- cas rm <hash> (was: cas rm <thread-id> <hash>)
- skill.ts develop topic updated to match
2026-05-08 09:23:39 +08:00
xiaoju aecce595e8 Merge pull request 'refactor: organize workflow/src into 6 module folders' (#104) from refactor/102-module-folders into main 2026-05-08 01:23:24 +00:00
86 changed files with 1120 additions and 685 deletions
+31 -2
View File
@@ -97,6 +97,36 @@ type WorkflowEntry = {
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
### Folder Module Discipline
Every folder under `src/` is a **module boundary**. Four rules:
| # | Rule | Rationale |
|---|------|-----------|
| 1 | **Every folder exports via `index.ts`** | Single entry point for the module |
| 2 | **Types live in `types.ts`** | Each folder's type definitions go in `<folder>/types.ts`, not scattered across files |
| 3 | **Single export source** | Only `index.ts` may re-export. No file may re-export from another module's internals. Cross-module imports must go through `index.ts` — never reach past it to import a specific file |
| 4 | **`index.ts` is pure re-exports** | No type definitions, no function implementations — only `export { ... } from` statements |
```typescript
// ✅ Good — import through module boundary
import { createCasStore } from "../cas/index.js";
import type { CasStore } from "../cas/index.js";
// ❌ Bad — reaching past index.ts
import { createCasStore } from "../cas/cas.js";
// ❌ Bad — re-exporting from non-index file
// in engine/engine.ts:
export { createCasStore } from "../cas/cas.js";
// ❌ Bad — types defined in index.ts
// in cas/index.ts:
export type CasStore = { ... }; // should be in cas/types.ts
```
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
## Naming
| Type | Style | Example |
@@ -197,9 +227,8 @@ Test files (`__tests__/**`) are exempt.
### Commands
```bash
bun run check # biome check (lint + format)
bun run check # tsc --build + biome check
bun run format # biome format --write
bun run build # full build
bun test # run tests
```
+1 -9
View File
@@ -1,4 +1,4 @@
import { createExtract, createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
import { createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
type Roles = {
@@ -32,12 +32,6 @@ const greeter: RoleDefinition<Roles["greeter"]> = {
extractMode: "single",
};
const extract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "",
});
export const run = createWorkflow<Roles>(
{
roles: { greeter },
@@ -48,6 +42,4 @@ export const run = createWorkflow<Roles>(
{
agent: async (ctx) => `Hello, ${ctx.start.content}`,
},
extract,
null,
);
@@ -1,4 +1,4 @@
import type { ParsedAddArgv } from "../src/commands/workflow/add-argv.js";
import type { ParsedAddArgv } from "../src/commands/workflow/index.js";
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
return { name, filePath, typesPath: null };
@@ -4,16 +4,16 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
import { cmdCasGet } from "../src/commands/cas/get.js";
import { cmdCasList } from "../src/commands/cas/list.js";
import { cmdCasPut } from "../src/commands/cas/put.js";
import { cmdCasRm } from "../src/commands/cas/rm.js";
import { cmdAdd } from "../src/commands/workflow/add.js";
import { cmdHistory } from "../src/commands/workflow/history.js";
import { cmdList, formatListLines } from "../src/commands/workflow/list.js";
import { cmdRemove } from "../src/commands/workflow/rm.js";
import { cmdRollback } from "../src/commands/workflow/rollback.js";
import { cmdShow } from "../src/commands/workflow/show.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
import {
cmdAdd,
cmdHistory,
cmdList,
cmdRemove,
cmdRollback,
cmdShow,
formatListLines,
} from "../src/commands/workflow/index.js";
import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
@@ -3,11 +3,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
import { cmdFork } from "../src/commands/thread/fork.js";
import { cmdRun } from "../src/commands/thread/run.js";
import { cmdAdd } from "../src/commands/workflow/add.js";
import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -78,6 +78,7 @@ describe("cli fork", () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await ensureTestWorkflowRegistryConfig(storageRoot);
});
afterEach(async () => {
@@ -10,7 +10,7 @@ import {
getGlobalCasDir,
putContentMerkleNode,
} from "@uncaged/workflow";
import { cmdThreadRemove } from "../src/commands/thread/rm.js";
import { cmdThreadRemove } from "../src/commands/thread/index.js";
import { pathExists } from "../src/fs-utils.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
@@ -4,8 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCli } from "../src/cli-dispatch.js";
import { cmdInitTemplate } from "../src/commands/init/template.js";
import { cmdInitWorkspace } from "../src/commands/init/workspace.js";
import { cmdInitTemplate, cmdInitWorkspace } from "../src/commands/init/index.js";
import { pathExists } from "../src/fs-utils.js";
describe("init template", () => {
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
import { cmdInitWorkspace } from "../src/commands/init/workspace.js";
import { cmdInitWorkspace } from "../src/commands/init/index.js";
import { pathExists } from "../src/fs-utils.js";
describe("init workspace", () => {
+1 -1
View File
@@ -13,7 +13,7 @@ import {
LIVE_CONTENT_MAX_LINES,
type LiveRoleRow,
renderLiveRoleStepLines,
} from "../src/commands/thread/live.js";
} from "../src/commands/thread/index.js";
import { parseLiveArgv } from "../src/live-argv.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
@@ -5,16 +5,21 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getGlobalCasDir } from "@uncaged/workflow";
import { cmdCasPut } from "../src/commands/cas/put.js";
import { cmdKill, cmdPause, cmdResume } from "../src/commands/thread/control.js";
import { cmdThreads } from "../src/commands/thread/list.js";
import { cmdPs } from "../src/commands/thread/ps.js";
import { cmdThreadRemove } from "../src/commands/thread/rm.js";
import { cmdRun } from "../src/commands/thread/run.js";
import { cmdThreadShow } from "../src/commands/thread/show.js";
import { cmdAdd } from "../src/commands/workflow/add.js";
import { cmdCasPut } from "../src/commands/cas/index.js";
import {
cmdKill,
cmdPause,
cmdPs,
cmdResume,
cmdRun,
cmdThreadRemove,
cmdThreadShow,
cmdThreads,
} from "../src/commands/thread/index.js";
import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`;
@@ -138,6 +143,7 @@ describe("cli thread commands", () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await ensureTestWorkflowRegistryConfig(storageRoot);
});
afterEach(async () => {
@@ -0,0 +1,18 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
/** Minimal valid global config so {@link executeThread} can resolve the extract scene (CLI integration tests). */
export const TEST_WORKFLOW_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`;
export async function ensureTestWorkflowRegistryConfig(storageRoot: string): Promise<void> {
await writeFile(join(storageRoot, "workflow.yaml"), TEST_WORKFLOW_REGISTRY_YAML, "utf8");
}
+4 -4
View File
@@ -2,8 +2,8 @@ import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
import { getCommandRegistry } from "./cli-registry.js";
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher, dispatchGc } from "./commands/cas/dispatch.js";
import { createInitDispatcher } from "./commands/init/dispatch.js";
import { createCasDispatcher, dispatchGc } from "./commands/cas/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import {
createThreadDispatcher,
dispatchFork,
@@ -14,7 +14,7 @@ import {
dispatchResume,
dispatchRun,
dispatchThreadList,
} from "./commands/thread/dispatch.js";
} from "./commands/thread/index.js";
import {
createWorkflowDispatcher,
dispatchAdd,
@@ -23,7 +23,7 @@ import {
dispatchRemove,
dispatchRollback,
dispatchShow,
} from "./commands/workflow/dispatch.js";
} from "./commands/workflow/index.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
+4 -4
View File
@@ -1,9 +1,9 @@
import type { CommandGroup } from "./cli-command-types.js";
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/dispatch.js";
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/dispatch.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/dispatch.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/dispatch.js";
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
return [
@@ -1,4 +1,4 @@
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
@@ -7,6 +7,7 @@ import { cmdCasGet } from "./get.js";
import { cmdCasList } from "./list.js";
import { cmdCasPut } from "./put.js";
import { cmdCasRm } from "./rm.js";
import type { CasDispatchDeps } from "./types.js";
function usageText(): string {
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
@@ -30,10 +31,9 @@ export async function dispatchGc(storageRoot: string, argv: string[]): Promise<n
}
export async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
if (threadId === undefined || hash === undefined || rest.length > 2) {
printCliError(`${usageText()}\n\nerror: cas get requires <thread-id> <hash>`);
const hash = rest[0];
if (hash === undefined || rest.length > 1) {
printCliError(`${usageText()}\n\nerror: cas get requires <hash>`);
return 1;
}
const result = await cmdCasGet(storageRoot, hash);
@@ -46,10 +46,9 @@ export async function dispatchCasGet(storageRoot: string, rest: string[]): Promi
}
export async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const content = rest[1];
if (threadId === undefined || content === undefined || rest.length > 2) {
printCliError(`${usageText()}\n\nerror: cas put requires <thread-id> <content>`);
const content = rest[0];
if (content === undefined || rest.length > 1) {
printCliError(`${usageText()}\n\nerror: cas put requires <content>`);
return 1;
}
const result = await cmdCasPut(storageRoot, content);
@@ -62,9 +61,8 @@ export async function dispatchCasPut(storageRoot: string, rest: string[]): Promi
}
export async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
if (threadId === undefined || rest.length > 1) {
printCliError(`${usageText()}\n\nerror: cas list requires <thread-id>`);
if (rest.length > 0) {
printCliError(`${usageText()}\n\nerror: cas list takes no arguments`);
return 1;
}
const result = await cmdCasList(storageRoot);
@@ -79,10 +77,9 @@ export async function dispatchCasList(storageRoot: string, rest: string[]): Prom
}
export async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
if (threadId === undefined || hash === undefined || rest.length > 2) {
printCliError(`${usageText()}\n\nerror: cas rm requires <thread-id> <hash>`);
const hash = rest[0];
if (hash === undefined || rest.length > 1) {
printCliError(`${usageText()}\n\nerror: cas rm requires <hash>`);
return 1;
}
const result = await cmdCasRm(storageRoot, hash);
@@ -97,24 +94,24 @@ export async function dispatchCasRm(storageRoot: string, rest: string[]): Promis
export const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
get: {
handler: dispatchCasGet,
args: "<thread-id> <hash>",
description: "Retrieve content by hash from a thread's CAS",
args: "<hash>",
description: "Retrieve content by hash from CAS",
},
put: {
handler: dispatchCasPut,
args: "<thread-id> <content>",
description: "Store content in a thread's CAS, returns hash",
args: "<content>",
description: "Store content in CAS, prints hash",
},
list: {
handler: dispatchCasList,
args: "<thread-id>",
description: "List all CAS entries for a thread",
args: "",
description: "List all hashes in CAS",
},
rm: { handler: dispatchCasRm, args: "<thread-id> <hash>", description: "Remove a CAS entry" },
rm: { handler: dispatchCasRm, args: "<hash>", description: "Remove a CAS entry by hash" },
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
};
export function createCasDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
export function createCasDispatcher(deps: CasDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
@@ -1,3 +1,12 @@
export {
CAS_SUBCOMMAND_TABLE,
createCasDispatcher,
dispatchCasGet,
dispatchCasList,
dispatchCasPut,
dispatchCasRm,
dispatchGc,
} from "./dispatch.js";
export { cmdGc } from "./gc.js";
export { cmdCasGet } from "./get.js";
export { cmdCasList } from "./list.js";
@@ -0,0 +1,5 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type CasDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -1,8 +1,9 @@
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
import { cmdInitTemplate } from "./template.js";
import type { InitDispatchDeps } from "./types.js";
import { cmdInitWorkspace } from "./workspace.js";
function usageText(): string {
@@ -52,7 +53,7 @@ export const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
},
};
export function createInitDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
export function createInitDispatcher(deps: InitDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv);
@@ -1,4 +1,9 @@
export type { CmdInitTemplateSuccess } from "./template.js";
export {
createInitDispatcher,
dispatchInitTemplate,
dispatchInitWorkspace,
INIT_SUBCOMMAND_TABLE,
} from "./dispatch.js";
export { cmdInitTemplate } from "./template.js";
export type { CmdInitWorkspaceSuccess } from "./workspace.js";
export type { CmdInitTemplateSuccess, CmdInitWorkspaceSuccess } from "./types.js";
export { cmdInitWorkspace } from "./workspace.js";
@@ -12,23 +12,8 @@ import {
templateRolesTs,
templateTsconfigJson,
} from "./templates.js";
export type CmdInitTemplateSuccess = {
templatePath: string;
};
function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) {
return err("workspace name must not be empty");
}
if (name === "." || name === "..") {
return err("invalid workspace name");
}
if (name.includes("/") || name.includes("\\")) {
return err("workspace name must not contain path separators");
}
return ok(undefined);
}
import type { CmdInitTemplateSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
return Array.isArray(workspaces) && workspaces.includes("templates/*");
@@ -0,0 +1,13 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type CmdInitTemplateSuccess = {
templatePath: string;
};
export type CmdInitWorkspaceSuccess = {
rootPath: string;
};
export type InitDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -0,0 +1,15 @@
import { err, ok, type Result } from "@uncaged/workflow";
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
export function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) {
return err("workspace name must not be empty");
}
if (name === "." || name === "..") {
return err("invalid workspace name");
}
if (name.includes("/") || name.includes("\\")) {
return err("workspace name must not contain path separators");
}
return ok(undefined);
}
@@ -4,23 +4,8 @@ import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow";
import { pathExists } from "../../fs-utils.js";
export type CmdInitWorkspaceSuccess = {
rootPath: string;
};
function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) {
return err("workspace name must not be empty");
}
if (name === "." || name === "..") {
return err("invalid workspace name");
}
if (name.includes("/") || name.includes("\\")) {
return err("workspace name must not contain path separators");
}
return ok(undefined);
}
import type { CmdInitWorkspaceSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function rootPackageJson(workspaceName: string): string {
return `${JSON.stringify(
@@ -122,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn****ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowFnOptions\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
@@ -1,4 +1,4 @@
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
@@ -13,6 +13,7 @@ import { cmdPs } from "./ps.js";
import { cmdThreadRemove } from "./rm.js";
import { cmdRun } from "./run.js";
import { cmdThreadShow } from "./show.js";
import type { ThreadDispatchDeps } from "./types.js";
function usageText(): string {
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
@@ -191,7 +192,7 @@ export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
};
export function createThreadDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
export function createThreadDispatcher(deps: ThreadDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
@@ -1,8 +1,8 @@
import { err, ok, type Result } from "@uncaged/workflow";
export function parseForkArgv(
argv: string[],
): Result<{ threadId: string; fromRole: string | null }, string> {
import type { ParsedForkArgv } from "./types.js";
export function parseForkArgv(argv: string[]): Result<ParsedForkArgv, string> {
if (argv.length === 0) {
return err("fork requires <thread-id>");
}
@@ -1,8 +1,21 @@
export { cmdKill, cmdPause, cmdResume } from "./control.js";
export {
createThreadDispatcher,
dispatchFork,
dispatchKill,
dispatchLive,
dispatchPause,
dispatchPs,
dispatchResume,
dispatchRun,
dispatchThreadList,
dispatchThreadRm,
dispatchThreadShow,
THREAD_SUBCOMMAND_TABLE,
} from "./dispatch.js";
export { cmdFork } from "./fork.js";
export { parseForkArgv } from "./fork-argv.js";
export { cmdThreads } from "./list.js";
export type { LiveRoleRow } from "./live.js";
export {
cmdLive,
formatLiveDebugLine,
@@ -14,3 +27,4 @@ export { cmdPs } from "./ps.js";
export { cmdThreadRemove } from "./rm.js";
export { cmdRun } from "./run.js";
export { cmdThreadShow } from "./show.js";
export type { LiveRoleRow } from "./types.js";
@@ -17,16 +17,10 @@ import { printCliError, printCliLine } from "../../cli-output.js";
import { pathExists } from "../../fs-utils.js";
import type { ParsedLiveArgv } from "../../live-argv.js";
import { findLatestThreadDataPath, resolveThreadDataPath } from "../../thread-scan.js";
import type { LiveRoleRow } from "./types.js";
export const LIVE_CONTENT_MAX_LINES = 10;
export type LiveRoleRow = {
role: string;
content: string;
meta: Record<string, unknown>;
timestamp: number;
};
export function formatLiveTimeLabel(timestampMs: number): string {
const d = new Date(timestampMs);
const hh = String(d.getHours()).padStart(2, "0");
@@ -0,0 +1,17 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type LiveRoleRow = {
role: string;
content: string;
meta: Record<string, unknown>;
timestamp: number;
};
export type ParsedForkArgv = {
threadId: string;
fromRole: string | null;
};
export type ThreadDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -1,11 +1,6 @@
import { err, ok, type Result } from "@uncaged/workflow";
export type ParsedAddArgv = {
name: string;
filePath: string;
/** Override path to `.d.ts` when adding a bundle. */
typesPath: string | null;
};
import type { ParsedAddArgv } from "./types.js";
type ParsedLongFlag = { advance: 2; kind: "types"; value: string };
@@ -17,12 +17,7 @@ import {
import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
import { validateCliWorkflowName } from "../../workflow-name.js";
import type { ParsedAddArgv } from "./add-argv.js";
export type CmdAddSuccess = {
hash: string;
warnings: ReadonlyArray<string>;
};
import type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
function isEsmBundle(path: string): boolean {
return path.endsWith(".esm.js");
@@ -1,4 +1,4 @@
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
@@ -9,6 +9,7 @@ import { cmdList, formatListLines } from "./list.js";
import { cmdRemove } from "./rm.js";
import { cmdRollback } from "./rollback.js";
import { cmdShow, formatShowYaml } from "./show.js";
import type { WorkflowDispatchDeps } from "./types.js";
function usageText(): string {
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
@@ -140,11 +141,6 @@ export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
},
};
type WorkflowDispatchDeps = {
dispatchGroup: DispatchGroupFn;
printDeprecation: (oldCmd: string, newCmd: string) => void;
};
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
const { dispatchGroup, printDeprecation } = deps;
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
@@ -1,9 +1,18 @@
export type { CmdAddSuccess } from "./add.js";
export { cmdAdd, formatAddSuccess } from "./add.js";
export type { ParsedAddArgv } from "./add-argv.js";
export { parseAddArgv } from "./add-argv.js";
export {
createWorkflowDispatcher,
dispatchAdd,
dispatchHistory,
dispatchList,
dispatchRemove,
dispatchRollback,
dispatchShow,
WORKFLOW_SUBCOMMAND_TABLE,
} from "./dispatch.js";
export { cmdHistory } from "./history.js";
export { cmdList, formatListLines } from "./list.js";
export { cmdRemove } from "./rm.js";
export { cmdRollback } from "./rollback.js";
export { cmdShow, formatShowYaml } from "./show.js";
export type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
@@ -0,0 +1,18 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type ParsedAddArgv = {
name: string;
filePath: string;
/** Override path to `.d.ts` when adding a bundle. */
typesPath: string | null;
};
export type CmdAddSuccess = {
hash: string;
warnings: ReadonlyArray<string>;
};
export type WorkflowDispatchDeps = {
dispatchGroup: DispatchGroupFn;
printDeprecation: (oldCmd: string, newCmd: string) => void;
};
+4 -4
View File
@@ -126,13 +126,13 @@ uncaged-workflow thread list
## CAS (Content-Addressable Storage)
Store and retrieve content by hash, scoped to the current thread.
Store and retrieve content by hash in workflow storage (global CAS directory).
| Operation | Command |
|-----------|---------|
| **Store** | \`uncaged-workflow cas put <THREAD_ID> '<content>'\` → prints hash |
| **Read** | \`uncaged-workflow cas get <THREAD_ID> <HASH>\` → prints content |
| **List** | \`uncaged-workflow cas list <THREAD_ID>\` |
| **Store** | \`uncaged-workflow cas put '<content>'\` → prints hash |
| **Read** | \`uncaged-workflow cas get <HASH>\` → prints content |
| **List** | \`uncaged-workflow cas list\` |
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
@@ -1,8 +1,6 @@
import {
type AgentBinding,
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
} from "@uncaged/workflow";
@@ -43,10 +41,6 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
moderator: developModerator,
};
export function createDevelopRun(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
export function createDevelopRun(binding: AgentBinding): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding);
}
@@ -250,17 +250,20 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir);
// Override developer so the test does not spin up a child workflow.
const run = createSolveIssueRun(
{
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
},
stubExtract,
stubLlmProvider,
);
const run = createSolveIssueRun({
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
);
const first = await gen.next();
expect(first.done).toBe(false);
@@ -294,33 +297,36 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir);
const calls: string[] = [];
const run = createSolveIssueRun(
{
agent: async () => {
calls.push("default");
const run = createSolveIssueRun({
agent: async () => {
calls.push("default");
return "";
},
overrides: {
preparer: async () => {
calls.push("preparer");
return "";
},
overrides: {
preparer: async () => {
calls.push("preparer");
return "";
},
developer: async () => {
calls.push("developer");
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
developer: async () => {
calls.push("developer");
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
},
stubExtract,
stubLlmProvider,
);
});
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
);
await gen.next();
expect(calls).toEqual(["preparer"]);
@@ -353,22 +359,25 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir);
let developerInvocations = 0;
const run = createSolveIssueRun(
{
agent: async () => "",
overrides: {
developer: async () => {
developerInvocations += 1;
return "stub-root-hash";
},
const run = createSolveIssueRun({
agent: async () => "",
overrides: {
developer: async () => {
developerInvocations += 1;
return "stub-root-hash";
},
},
stubExtract,
stubLlmProvider,
);
});
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
);
// preparer
await gen.next();
@@ -1,8 +1,6 @@
import {
type AgentBinding,
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
workflowAsAgent,
@@ -46,11 +44,7 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
*/
export function createSolveIssueRun(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
export function createSolveIssueRun(binding: AgentBinding): WorkflowFn {
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
const mergedBinding: AgentBinding = {
agent: binding.agent,
@@ -59,5 +53,5 @@ export function createSolveIssueRun(
developer: developerOverride,
},
};
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider);
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding);
}
+25 -15
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as z from "zod/v4";
@@ -13,8 +13,7 @@ import {
} from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { createExtract } from "../src/extract/extract-fn.js";
import { END, type LlmProvider } from "../src/types.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const plannerMetaSchema = z.object({
@@ -82,11 +81,20 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
};
}
const demoExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "test",
model: "test",
});
const EXTRACT_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
async function writeExtractRegistryConfig(storageRoot: string): Promise<void> {
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
}
const demoWorkflow = createWorkflow<DemoMeta>(
{
@@ -125,8 +133,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
coder: async () => "code-body",
},
},
demoExtract,
null,
);
describe("executeThread", () => {
@@ -150,6 +156,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -166,6 +173,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -258,6 +266,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas"));
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
@@ -295,6 +304,7 @@ describe("executeThread", () => {
timestamp: histTs,
},
],
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -354,6 +364,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -391,6 +402,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -407,6 +419,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -549,9 +562,6 @@ describe("executeThread", () => {
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const llm: LlmProvider = { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" };
const extractFn = createExtract(llm);
const dagWorkflow = createWorkflow<DagDemoMeta>(
{
roles: {
@@ -568,8 +578,6 @@ describe("executeThread", () => {
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
},
{ agent: async () => dagRootHash },
extractFn,
llm,
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -577,6 +585,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController();
@@ -592,6 +601,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -1,87 +0,0 @@
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 { getExtractProvider } from "../src/extract-provider.js";
describe("getExtractProvider", () => {
test("returns provider when config.extract is present", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
extract:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: literal-key
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
expect(r.value.model).toBe("qwen-plus");
expect(r.value.apiKey).toBe("literal-key");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("errs when registry has no config section", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
const r = await getExtractProvider(root);
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no global config");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("resolves apiKey from env at registry read time", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 1
extract:
baseUrl: https://example.com
model: m
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.apiKey).toBe("resolved-secret");
} finally {
if (prev === undefined) {
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
} else {
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
}
await rm(root, { recursive: true, force: true });
}
});
});
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as z from "zod/v4";
@@ -8,7 +8,6 @@ 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 { createExtract } from "../src/extract/extract-fn.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
@@ -76,11 +75,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
};
}
const refsDemoExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "test",
model: "test",
});
const EXTRACT_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
{
@@ -99,8 +103,6 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
{
agent: async () => "plan-output",
},
refsDemoExtract,
null,
);
describe("RoleStep refs tracking", () => {
@@ -142,6 +144,7 @@ describe("RoleStep refs tracking", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeFile(join(root, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -158,6 +161,7 @@ describe("RoleStep refs tracking", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
+25 -16
View File
@@ -105,10 +105,13 @@ describe("workflow registry", () => {
const yaml = `
config:
maxDepth: 3
extract:
baseUrl: https://example.com/v1
model: qwen-plus
apiKey: secret-key
providers:
dashscope:
baseUrl: https://example.com/v1
apiKey: secret-key
models:
default: dashscope/qwen-turbo
extract: dashscope/qwen-plus
workflows:
solve-issue:
hash: SPVR4BDMSGC1W
@@ -125,9 +128,10 @@ workflows:
return;
}
expect(r.value.config.maxDepth).toBe(3);
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1");
expect(r.value.config.extract.model).toBe("qwen-plus");
expect(r.value.config.extract.apiKey).toBe("secret-key");
expect(r.value.config.providers.dashscope?.baseUrl).toBe("https://example.com/v1");
expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
});
test("parses config apiKey env: prefix from process.env", () => {
@@ -137,10 +141,13 @@ workflows:
const yaml = `
config:
maxDepth: 1
extract:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: env:WF_REGISTRY_TEST_API_KEY
providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: env:WF_REGISTRY_TEST_API_KEY
models:
default: dashscope/qwen-plus
extract: dashscope/qwen-plus
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
@@ -148,7 +155,7 @@ workflows: {}
if (!r.ok) {
return;
}
expect(r.value.config?.extract.apiKey).toBe("from-env");
expect(r.value.config?.providers.dashscope?.apiKey).toBe("from-env");
} finally {
if (prev === undefined) {
delete process.env.WF_REGISTRY_TEST_API_KEY;
@@ -165,10 +172,12 @@ workflows: {}
const yaml = `
config:
maxDepth: 1
extract:
baseUrl: https://example.com
model: m
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
providers:
p:
baseUrl: https://example.com
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
models:
default: p/m
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
@@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test";
import { resolveModel } from "../src/config/resolve-model.js";
import type { WorkflowConfig } from "../src/registry/index.js";
function sampleConfig(): WorkflowConfig {
return {
maxDepth: 3,
providers: {
dashscope: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "secret",
},
other: {
baseUrl: "https://other.example/v1",
apiKey: "k2",
},
},
models: {
default: "dashscope/qwen-plus",
extract: "other/foo/bar-model",
},
};
}
describe("resolveModel", () => {
test("uses explicit scene mapping", () => {
const config = sampleConfig();
const r = resolveModel(config, "extract");
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://other.example/v1");
expect(r.value.apiKey).toBe("k2");
expect(r.value.model).toBe("foo/bar-model");
});
test("falls back to models.default when scene is missing", () => {
const config = sampleConfig();
const r = resolveModel(config, "unknown-scene");
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.model).toBe("qwen-plus");
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
});
test("errs when scene missing and no default", () => {
const config: WorkflowConfig = {
maxDepth: 1,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
extract: "p/m",
},
};
const r = resolveModel(config, "other");
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no model mapping");
expect(r.error).toContain("default");
});
test("errs when provider is unknown", () => {
const config: WorkflowConfig = {
maxDepth: 1,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
default: "missing/m",
},
};
const r = resolveModel(config, "any");
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("unknown provider");
});
test("errs on invalid model reference shape", () => {
const config: WorkflowConfig = {
maxDepth: 1,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
default: "no-slash-model",
},
};
const r = resolveModel(config, "x");
expect(r.ok).toBe(false);
});
});
@@ -9,6 +9,17 @@ import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js";
const WORKER_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
export const descriptor = {
@@ -89,6 +100,7 @@ describe("worker process", () => {
try {
const hash = "C9NMV6V2TQT81";
await mkdir(join(root, "bundles"), { recursive: true });
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
await writeFile(bundlePath, bundleSource, "utf8");
@@ -136,6 +148,7 @@ describe("worker process", () => {
try {
const hash = "C9NMV6V2TQT81";
await mkdir(join(root, "bundles"), { recursive: true });
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
await writeFile(bundlePath, bundleSource, "utf8");
@@ -9,7 +9,6 @@ import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { createExtract } from "../src/extract/extract-fn.js";
import {
readWorkflowRegistry,
registerWorkflowVersion,
@@ -76,11 +75,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
};
}
const parentExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "test",
model: "test",
});
const PARENT_REGISTRY_WITH_CONFIG = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`;
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -131,6 +135,8 @@ describe("workflowAsAgent integration", () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), PARENT_REGISTRY_WITH_CONFIG, "utf8");
const { hash: childHash } = await installChildWorkflow(root);
const parentWorkflow = createWorkflow<ParentMeta>(
@@ -148,8 +154,6 @@ describe("workflowAsAgent integration", () => {
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
},
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
parentExtract,
null,
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -173,6 +177,7 @@ describe("workflowAsAgent integration", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -93,6 +93,21 @@ describe("workflowAsAgent", () => {
test("runs registered workflow and returns child thread root CAS hash", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`,
"utf8",
);
await installChildWorkflow(root);
const agent = workflowAsAgent("child-wf", { storageRoot: root });
const out = await agent(
@@ -140,10 +155,15 @@ describe("workflowAsAgent", () => {
...reg.value,
config: {
maxDepth: 2,
extract: {
baseUrl: "http://127.0.0.1:9",
model: "m",
apiKey: "k",
providers: {
local: {
baseUrl: "http://127.0.0.1:9",
apiKey: "k",
},
},
models: {
default: "local/m",
extract: "local/m",
},
},
};
@@ -1,7 +1,7 @@
import * as z from "zod/v4";
import type { RoleMeta, WorkflowDefinition } from "../types.js";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./workflow-descriptor.js";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
const { $schema: _drop, ...rest } = json;
@@ -10,6 +10,11 @@ import type {
Program,
VariableDeclaration,
} from "acorn";
import * as acorn from "acorn";
import { err, ok, type Result } from "../util/index.js";
import type { WorkflowBundleValidationInput } from "./types.js";
/** Acorn Node with index-access for property traversal. */
type AcornNode = Node & { [key: string]: unknown };
@@ -22,17 +27,6 @@ function narrowNode<T extends Node>(node: Node): T {
return node as unknown as T;
}
import * as acorn from "acorn";
import { err, ok, type Result } from "../util/result.js";
export type WorkflowBundleValidationInput = {
/** Absolute or relative path (used for `.esm.js` suffix checks). */
filePath: string;
/** UTF-8 source of the bundle. */
source: string;
};
function endsWithEsmJs(path: string): boolean {
return path.endsWith(".esm.js");
}
@@ -1,20 +1,10 @@
import type { WorkflowFn } from "../types.js";
import { err, ok, type Result } from "../util/result.js";
import { err, ok, type Result } from "../util/index.js";
import { importWorkflowBundleModule } from "./bundle-import-env.js";
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
import type { WorkflowDescriptor } from "./workflow-descriptor.js";
import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js";
import { validateWorkflowDescriptor } from "./workflow-descriptor.js";
export type ExtractedBundleExports = {
run: WorkflowFn;
descriptor: WorkflowDescriptor;
};
export type ExtractBundleExportsOptions = {
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
storageRoot: string | null;
};
/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */
export async function extractBundleExports(
bundlePath: string,
@@ -1,6 +1,6 @@
import { stringify } from "yaml";
import type { WorkflowDescriptor } from "./workflow-descriptor.js";
import type { WorkflowDescriptor } from "./types.js";
/** Serialize a validated workflow descriptor to YAML for storage next to the bundle. */
export function stringifyWorkflowDescriptor(descriptor: WorkflowDescriptor): string {
+15
View File
@@ -0,0 +1,15 @@
export { buildDescriptor } from "./build-descriptor.js";
export { importWorkflowBundleModule } from "./bundle-import-env.js";
export { validateWorkflowBundle } from "./bundle-validator.js";
export { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
export { extractBundleExports } from "./extract-bundle-exports.js";
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
export type {
ExtractBundleExportsOptions,
ExtractedBundleExports,
WorkflowBundleValidationInput,
WorkflowDescriptor,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./types.js";
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
+32
View File
@@ -0,0 +1,32 @@
import type { WorkflowFn } from "../types.js";
/** 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 WorkflowBundleValidationInput = {
/** Absolute or relative path (used for `.esm.js` suffix checks). */
filePath: string;
/** UTF-8 source of the bundle. */
source: string;
};
export type ExtractedBundleExports = {
run: WorkflowFn;
descriptor: WorkflowDescriptor;
};
export type ExtractBundleExportsOptions = {
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
storageRoot: string | null;
};
@@ -1,18 +1,6 @@
import { err, ok, type Result } from "../util/result.js";
import { err, ok, type Result } from "../util/index.js";
/** 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>;
};
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)) {
+1 -7
View File
@@ -2,13 +2,7 @@ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/pro
import { join } from "node:path";
import { hashString } from "./hash.js";
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
import type { CasStore } from "./types.js";
export function createCasStore(casDir: string): CasStore {
async function ensureDir(): Promise<void> {
+1 -1
View File
@@ -2,7 +2,7 @@ import { Buffer } from "node:buffer";
import XXH from "xxhashjs";
import { encodeUint64AsCrockford } from "../util/base32.js";
import { encodeUint64AsCrockford } from "../util/index.js";
function digestToUint64(digest: { toString(radix?: number): string }): bigint {
const hex = digest.toString(16).padStart(16, "0");
+18
View File
@@ -0,0 +1,18 @@
export { createCasStore, createThreadCas } from "./cas.js";
export { hashString, hashWorkflowBundleBytes } from "./hash.js";
export {
createContentMerkleNode,
getContentMerklePayload,
parseMerkleNode,
putContentMerkleNode,
putStepMerkleNode,
putThreadMerkleNode,
serializeMerkleNode,
} from "./merkle.js";
export type {
CasStore,
MerkleNode,
MerkleNodeType,
StepMerklePayload,
ThreadMerklePayload,
} from "./types.js";
+1 -23
View File
@@ -1,14 +1,6 @@
import { parse, stringify } from "yaml";
import type { CasStore } from "./cas.js";
export type MerkleNodeType = "content" | "step" | "thread";
export type MerkleNode = {
type: MerkleNodeType;
payload: string | Record<string, unknown>;
children: string[];
};
import type { CasStore, MerkleNode, StepMerklePayload, ThreadMerklePayload } from "./types.js";
export function serializeMerkleNode(node: MerkleNode): string {
return stringify(
@@ -53,20 +45,6 @@ export function createContentMerkleNode(payload: string): MerkleNode {
return { type: "content", payload, children: [] };
}
export type StepMerklePayload = {
role: string;
meta: Record<string, unknown>;
};
export type ThreadMerklePayload = {
workflow: string;
threadId: string;
result: {
returnCode: number;
summary: string;
};
};
/** Serializes a step Merkle node (role + meta + content child) and stores it in CAS. */
export async function putStepMerkleNode(
store: CasStore,
+28
View File
@@ -0,0 +1,28 @@
export type CasStore = {
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 MerkleNode = {
type: MerkleNodeType;
payload: string | Record<string, unknown>;
children: string[];
};
export type StepMerklePayload = {
role: string;
meta: Record<string, unknown>;
};
export type ThreadMerklePayload = {
workflow: string;
threadId: string;
result: {
returnCode: number;
summary: string;
};
};
+3
View File
@@ -0,0 +1,3 @@
export { resolveModel } from "./resolve-model.js";
export { splitProviderModelRef } from "./split-provider-model-ref.js";
export type { ProviderConfig, ResolvedModel } from "./types.js";
@@ -0,0 +1,30 @@
import type { WorkflowConfig } from "../registry/index.js";
import { err, ok, type Result } from "../util/index.js";
import { splitProviderModelRef } from "./split-provider-model-ref.js";
import type { ResolvedModel } from "./types.js";
/** Resolves scene → provider endpoint + model using {@link WorkflowConfig.providers} and {@link WorkflowConfig.models}. */
export function resolveModel(config: WorkflowConfig, scene: string): Result<ResolvedModel, string> {
const models = config.models;
let ref = models[scene] ?? null;
if (ref === null) {
ref = models.default ?? null;
}
if (ref === null) {
return err(`no model mapping for scene "${scene}" and no models.default fallback`);
}
const split = splitProviderModelRef(ref);
if (!split.ok) {
return split;
}
const { providerName, modelName } = split.value;
const provider = config.providers[providerName] ?? null;
if (provider === null) {
return err(`unknown provider "${providerName}" referenced by scene "${scene}"`);
}
return ok({
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
model: modelName,
});
}
@@ -0,0 +1,17 @@
import { err, ok, type Result } from "../util/index.js";
/** Parses `providerName/modelName` references used in {@link WorkflowConfig.models}. */
export function splitProviderModelRef(
ref: string,
): Result<{ providerName: string; modelName: string }, string> {
const idx = ref.indexOf("/");
if (idx <= 0 || idx === ref.length - 1) {
return err(`invalid model reference "${ref}": expected providerName/modelName`);
}
const providerName = ref.slice(0, idx);
const modelName = ref.slice(idx + 1);
if (providerName === "" || modelName === "") {
return err(`invalid model reference "${ref}": expected providerName/modelName`);
}
return ok({ providerName, modelName });
}
+10
View File
@@ -0,0 +1,10 @@
export type ProviderConfig = {
baseUrl: string;
apiKey: string;
};
export type ResolvedModel = {
baseUrl: string;
apiKey: string;
model: string;
};
+13 -22
View File
@@ -1,13 +1,10 @@
import type { CasStore } from "../cas/cas.js";
import { putContentMerkleNode } from "../cas/merkle.js";
import { buildExtractUserContent, type ExtractFn } from "../extract/extract-fn.js";
import { reactExtract } from "../extract/react-extract.js";
import { putContentMerkleNode } from "../cas/index.js";
import { buildExtractUserContent, reactExtract } from "../extract/index.js";
import {
type AgentBinding,
type AgentContext,
END,
type ExtractContext,
type LlmProvider,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
@@ -20,7 +17,7 @@ import {
type WorkflowFn,
type WorkflowFnOptions,
} from "../types.js";
import { mergeRefsWithContentHash } from "../util/refs-field.js";
import { mergeRefsWithContentHash } from "../util/index.js";
function isRoleNext<M extends RoleMeta>(
next: (keyof M & string) | typeof END,
@@ -42,14 +39,12 @@ function resolveExtractedRefs(
async function resolveRoleMeta<M extends RoleMeta>(
roleDef: RoleDefinition<Record<string, unknown>>,
extractCtx: ExtractContext<M>,
extract: ExtractFn,
llmProvider: LlmProvider | null,
cas: CasStore,
options: WorkflowFnOptions,
): Promise<Record<string, unknown>> {
if (roleDef.extractMode === "react") {
if (llmProvider === null) {
if (options.llmProvider === null) {
throw new Error(
'createWorkflow: llmProvider is required when a role uses extractMode "react"',
'createWorkflow: WorkflowFnOptions.llmProvider is required when a role uses extractMode "react"',
);
}
const text = await buildExtractUserContent(
@@ -59,15 +54,15 @@ async function resolveRoleMeta<M extends RoleMeta>(
const reactResult = await reactExtract({
text,
schema: roleDef.schema,
provider: llmProvider,
cas,
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 extract(
return (await options.extract(
roleDef.schema,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
@@ -75,15 +70,13 @@ async function resolveRoleMeta<M extends RoleMeta>(
}
/**
* Binds pure role definitions + moderator to runtime agents and structured extraction.
* Assign with `export const run = createWorkflow(def, binding, extract, llmProvider)`.
* Pass the same {@link LlmProvider} as {@link createExtract} when any role uses `extractMode: "react"`.
* Binds pure role definitions + moderator to runtime agents.
* Assign with `export const run = createWorkflow(def, binding)`.
* The engine supplies {@link WorkflowFnOptions.extract} and {@link WorkflowFnOptions.llmProvider} from workflow.yaml.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
return async function* workflowLoop(
input: ThreadInput,
@@ -150,9 +143,7 @@ export function createWorkflow<M extends RoleMeta>(
const meta = await resolveRoleMeta(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
extractCtx,
extract,
llmProvider,
options.cas,
options,
);
const contentHash = await putContentMerkleNode(options.cas, raw);
+44 -35
View File
@@ -1,50 +1,52 @@
import { appendFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import type { CasStore } from "../cas/cas.js";
import { getContentMerklePayload, putStepMerkleNode, putThreadMerkleNode } from "../cas/merkle.js";
import {
type CasStore,
getContentMerklePayload,
putStepMerkleNode,
putThreadMerkleNode,
} from "../cas/index.js";
import { resolveModel } from "../config/index.js";
import { createExtract } from "../extract/index.js";
import { readWorkflowRegistry } from "../registry/index.js";
import type {
LlmProvider,
ThreadInput,
WorkflowCompletion,
WorkflowFn,
WorkflowFnOptions,
WorkflowResult,
} from "../types.js";
import type { LogFn } from "../util/logger.js";
import { normalizeRefsField } from "../util/refs-field.js";
import { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/index.js";
export type ExecuteThreadIo = {
threadId: string;
hash: string;
dataJsonlPath: string;
infoJsonlPath: string;
cas: CasStore;
};
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
/** One persisted role line in `.data.jsonl` (engine adds these for fork replay before running the generator). */
export type PrefilledDiskStep = {
role: string;
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
timestamp: number;
};
export type ExecuteThreadOptions = {
maxRounds: number;
/** Passed to the bundle as `WorkflowFnOptions.depth`. */
depth: number;
signal: AbortSignal;
/** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */
awaitAfterEachYield: () => Promise<void>;
/** When non-null, written into the start record so tooling can trace lineage. */
forkSourceThreadId: string | null;
/**
* Written to `.data.jsonl` immediately after the start record, before the generator runs.
* Must match `input.steps` length and order when present.
*/
prefilledDiskSteps: PrefilledDiskStep[] | null;
};
async function resolveExtractRuntime(
storageRoot: string,
): Promise<
Result<{ extract: ReturnType<typeof createExtract>; llmProvider: LlmProvider }, string>
> {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return err(reg.error.message);
}
const cfg = reg.value.config;
if (cfg === null) {
return err("workflow registry has no global config section");
}
const resolved = resolveModel(cfg, "extract");
if (!resolved.ok) {
return resolved;
}
const ex = resolved.value;
const llmProvider: LlmProvider = {
baseUrl: ex.baseUrl,
apiKey: ex.apiKey,
model: ex.model,
};
return ok({ extract: createExtract(llmProvider), llmProvider });
}
async function appendDataLine(path: string, record: unknown): Promise<void> {
const line = `${JSON.stringify(record)}\n`;
@@ -278,11 +280,18 @@ export async function executeThread(
});
}
const extractRuntime = await resolveExtractRuntime(options.storageRoot);
if (!extractRuntime.ok) {
throw new Error(extractRuntime.error);
}
const bundleOptions: WorkflowFnOptions = {
threadId: io.threadId,
maxRounds: options.maxRounds,
depth: options.depth,
cas: io.cas,
extract: extractRuntime.value.extract,
llmProvider: extractRuntime.value.llmProvider,
};
return await driveWorkflowGenerator({
+3 -23
View File
@@ -1,18 +1,7 @@
import type { RoleOutput, WorkflowCompletion } from "../types.js";
import { normalizeRefsField } from "../util/refs-field.js";
import { err, ok, type Result } from "../util/result.js";
import type { WorkflowCompletion } from "../types.js";
import { err, normalizeRefsField, ok, type Result } from "../util/index.js";
/** Role steps replayed from `.data.jsonl`, including persisted timestamps. */
export type ForkHistoricalStep = RoleOutput & { timestamp: number };
export type ParsedThreadStartRecord = {
workflowName: string;
hash: string;
threadId: string;
prompt: string;
maxRounds: number;
depth: number;
};
import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.js";
/** Recognizes a persisted workflow completion line (no `role`; has numeric `returnCode` and string `summary`). Omits `rootHash` when absent. */
export function tryParseWorkflowResultRecord(
@@ -228,15 +217,6 @@ export function selectForkHistoricalSteps(
return ok(roleSteps.slice(0, idx + 1));
}
export type ForkPlan = {
workflowName: string;
hash: string;
sourceThreadId: string;
prompt: string;
runOptions: { maxRounds: number; depth: number };
historicalSteps: ForkHistoricalStep[];
};
/**
* Read `.data.jsonl` text and compute fork payload for the worker `run` command.
*/
+3 -10
View File
@@ -1,16 +1,9 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { type CasStore, createCasStore } from "../cas/cas.js";
import { err, ok, type Result } from "../util/result.js";
import { getGlobalCasDir } from "../util/storage-root.js";
import { type CasStore, createCasStore } from "../cas/index.js";
import { err, getGlobalCasDir, ok, type Result } from "../util/index.js";
import { parseThreadDataJsonl } from "./fork-thread.js";
export type GcResult = {
scannedThreads: number;
activeRefs: number;
deletedEntries: number;
deletedHashes: string[];
};
import type { GcResult } from "./types.js";
async function listThreadDataJsonlPaths(storageRoot: string): Promise<Result<string[], string>> {
const logsRoot = join(storageRoot, "logs");
+22
View File
@@ -0,0 +1,22 @@
export { createWorkflow } from "./create-workflow.js";
export { executeThread } from "./engine.js";
export {
buildForkPlan,
parseThreadDataJsonl,
selectForkHistoricalSteps,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
} from "./fork-thread.js";
export { garbageCollectCas } from "./gc.js";
export { createThreadPauseGate } from "./thread-pause-gate.js";
export type {
ExecuteThreadIo,
ExecuteThreadOptions,
ForkHistoricalStep,
ForkPlan,
GcResult,
ParsedThreadStartRecord,
PrefilledDiskStep,
ThreadPauseGate,
} from "./types.js";
export { getWorkerHostScriptPath } from "./worker-entry-path.js";
@@ -1,11 +1,6 @@
import { err, ok, type Result } from "../util/result.js";
import { err, ok, type Result } from "../util/index.js";
export type ThreadPauseGate = {
awaitAfterYield: () => Promise<void>;
pause: () => Result<void, string>;
resume: () => Result<void, string>;
isPaused: () => boolean;
};
import type { ThreadPauseGate } from "./types.js";
/**
* Pause/resume gate for workflow threads: after each generator yield the engine awaits
+73
View File
@@ -0,0 +1,73 @@
import type { CasStore } from "../cas/index.js";
import type { RoleOutput } from "../types.js";
import type { Result } from "../util/index.js";
export type ExecuteThreadIo = {
threadId: string;
hash: string;
dataJsonlPath: string;
infoJsonlPath: string;
cas: CasStore;
};
/** One persisted role line in `.data.jsonl` (engine adds these for fork replay before running the generator). */
export type PrefilledDiskStep = {
role: string;
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
timestamp: number;
};
export type ExecuteThreadOptions = {
maxRounds: number;
/** Passed to the bundle as `WorkflowFnOptions.depth`. */
depth: number;
signal: AbortSignal;
/** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */
awaitAfterEachYield: () => Promise<void>;
/** When non-null, written into the start record so tooling can trace lineage. */
forkSourceThreadId: string | null;
/**
* Written to `.data.jsonl` immediately after the start record, before the generator runs.
* Must match `input.steps` length and order when present.
*/
prefilledDiskSteps: PrefilledDiskStep[] | null;
/** Workspace root containing `workflow.yaml`; used to resolve the `extract` scene for meta extraction. */
storageRoot: string;
};
/** Role steps replayed from `.data.jsonl`, including persisted timestamps. */
export type ForkHistoricalStep = RoleOutput & { timestamp: number };
export type ParsedThreadStartRecord = {
workflowName: string;
hash: string;
threadId: string;
prompt: string;
maxRounds: number;
depth: number;
};
export type ForkPlan = {
workflowName: string;
hash: string;
sourceThreadId: string;
prompt: string;
runOptions: { maxRounds: number; depth: number };
historicalSteps: ForkHistoricalStep[];
};
export type GcResult = {
scannedThreads: number;
activeRefs: number;
deletedEntries: number;
deletedHashes: string[];
};
export type ThreadPauseGate = {
awaitAfterYield: () => Promise<void>;
pause: () => Result<void, string>;
resume: () => Result<void, string>;
isPaused: () => boolean;
};
+14 -10
View File
@@ -1,17 +1,20 @@
import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type Socket } from "node:net";
import { dirname, join } from "node:path";
import { importWorkflowBundleModule } from "../bundle/bundle-import-env.js";
import { ensureUncagedWorkflowSymlink } from "../bundle/ensure-uncaged-workflow-symlink.js";
import { createCasStore } from "../cas/cas.js";
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "../bundle/index.js";
import { createCasStore } from "../cas/index.js";
import type { RoleOutput, WorkflowFn, WorkflowResult } from "../types.js";
import { createLogger } from "../util/logger.js";
import { normalizeRefsField } from "../util/refs-field.js";
import { err, ok, type Result } from "../util/result.js";
import { getGlobalCasDir } from "../util/storage-root.js";
import type { PrefilledDiskStep } from "./engine.js";
import { type ExecuteThreadIo, executeThread } from "./engine.js";
import { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
import {
createLogger,
err,
getGlobalCasDir,
normalizeRefsField,
ok,
type Result,
} from "../util/index.js";
import { executeThread } from "./engine.js";
import { createThreadPauseGate } from "./thread-pause-gate.js";
import type { ExecuteThreadIo, PrefilledDiskStep, ThreadPauseGate } from "./types.js";
const bootLog = createLogger({ sink: { kind: "stderr" } });
@@ -414,6 +417,7 @@ async function main(): Promise<void> {
awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
forkSourceThreadId: cmd.forkSourceThreadId,
prefilledDiskSteps,
storageRoot,
},
io,
logger,
-35
View File
@@ -1,35 +0,0 @@
import { readWorkflowRegistry } from "./registry/registry.js";
import type { WorkflowConfig } from "./registry/registry-types.js";
import type { LlmProvider } from "./types.js";
import { err, ok, type Result } from "./util/result.js";
import { getDefaultWorkflowStorageRoot } from "./util/storage-root.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
export function getWorkflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
if (config === null) {
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
}
return config.maxDepth;
}
/** Loads `config.extract` from workflow.yaml (apiKey already resolved at registry parse time). */
export async function getExtractProvider(
storageRoot: string | undefined,
): Promise<Result<LlmProvider, string>> {
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
const regResult = await readWorkflowRegistry(root);
if (!regResult.ok) {
return err(regResult.error.message);
}
const cfg = regResult.value.config;
if (cfg === null) {
return err("workflow registry has no global config section");
}
const ex = cfg.extract;
return ok({
baseUrl: ex.baseUrl,
apiKey: ex.apiKey,
model: ex.model,
});
}
+3 -7
View File
@@ -1,13 +1,9 @@
import type * as z from "zod/v4";
import { getContentMerklePayload } from "../cas/merkle.js";
import { getContentMerklePayload } from "../cas/index.js";
import type { ExtractContext, LlmProvider } from "../types.js";
import { llmExtractWithRetry } from "./llm-extract.js";
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<T>;
import type { ExtractFn } from "./types.js";
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
export async function buildExtractUserContent(
+17
View File
@@ -0,0 +1,17 @@
export {
buildExtractUserContent,
createExtract,
} from "./extract-fn.js";
export {
extractFunctionToolFromZodSchema,
llmErrorToCause,
llmExtract,
llmExtractWithRetry,
} from "./llm-extract.js";
export { reactExtract } from "./react-extract.js";
export type {
ExtractFn,
LlmError,
LlmExtractArgs,
ReactExtractArgs,
} from "./types.js";
+2 -14
View File
@@ -1,20 +1,8 @@
import * as z from "zod/v4";
import type { LlmProvider } from "../types.js";
import { err, ok, type Result } from "../util/result.js";
export type LlmExtractArgs<T> = {
text: string;
schema: z.ZodType<T>;
provider: LlmProvider;
};
import { err, ok, type Result } from "../util/index.js";
export type LlmError =
| { kind: "http_error"; status: number; body: string }
| { kind: "invalid_response_json"; message: string }
| { kind: "no_tool_call"; preview: string }
| { kind: "tool_arguments_invalid_json"; message: string }
| { kind: "schema_validation_failed"; message: string }
| { kind: "network_error"; message: string };
import type { LlmError, LlmExtractArgs } from "./types.js";
function chatCompletionsUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
@@ -1,16 +1,11 @@
import type * as z from "zod/v4";
import type { CasStore } from "../cas/cas.js";
import type { CasStore } from "../cas/index.js";
import type { LlmProvider } from "../types.js";
import { err, ok, type Result } from "../util/result.js";
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
import { err, ok, type Result } from "../util/index.js";
export type ReactExtractArgs<T extends Record<string, unknown>> = {
text: string;
schema: z.ZodType<T>;
provider: LlmProvider;
cas: CasStore;
};
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
import type { ReactExtractArgs } from "./types.js";
const MAX_REACT_ROUNDS = 10;
+31
View File
@@ -0,0 +1,31 @@
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 ReactExtractArgs<T extends Record<string, unknown>> = {
text: string;
schema: z.ZodType<T>;
provider: LlmProvider;
cas: CasStore;
};
export type LlmExtractArgs<T> = {
text: string;
schema: z.ZodType<T>;
provider: LlmProvider;
};
export type LlmError =
| { kind: "http_error"; status: number; body: string }
| { kind: "invalid_response_json"; message: string }
| { kind: "no_tool_call"; preview: string }
| { kind: "tool_arguments_invalid_json"; message: string }
| { kind: "schema_validation_failed"; message: string }
| { kind: "network_error"; message: string };
+40 -35
View File
@@ -1,24 +1,23 @@
export { buildDescriptor } from "./bundle/build-descriptor.js";
export {
validateWorkflowBundle,
type WorkflowBundleValidationInput,
} from "./bundle/bundle-validator.js";
export {
buildDescriptor,
type ExtractedBundleExports,
extractBundleExports,
} from "./bundle/extract-bundle-exports.js";
export { stringifyWorkflowDescriptor } from "./bundle/generate-descriptor.js";
export {
stringifyWorkflowDescriptor,
validateWorkflowBundle,
validateWorkflowDescriptor,
type WorkflowBundleValidationInput,
type WorkflowDescriptor,
type WorkflowRoleDescriptor,
type WorkflowRoleSchema,
} from "./bundle/workflow-descriptor.js";
export { type CasStore, createCasStore, createThreadCas } from "./cas/cas.js";
export { hashString, hashWorkflowBundleBytes } from "./cas/hash.js";
} from "./bundle/index.js";
export {
type CasStore,
createCasStore,
createContentMerkleNode,
createThreadCas,
getContentMerklePayload,
hashString,
hashWorkflowBundleBytes,
type MerkleNode,
type MerkleNodeType,
parseMerkleNode,
@@ -28,38 +27,43 @@ export {
type StepMerklePayload,
serializeMerkleNode,
type ThreadMerklePayload,
} from "./cas/merkle.js";
export { createWorkflow } from "./engine/create-workflow.js";
} from "./cas/index.js";
export {
type ProviderConfig,
type ResolvedModel,
resolveModel,
} from "./config/index.js";
export {
buildForkPlan,
createThreadPauseGate,
createWorkflow,
type ExecuteThreadIo,
type ExecuteThreadOptions,
executeThread,
type PrefilledDiskStep,
} from "./engine/engine.js";
export {
buildForkPlan,
type ForkHistoricalStep,
type ForkPlan,
type GcResult,
garbageCollectCas,
getWorkerHostScriptPath,
type ParsedThreadStartRecord,
type PrefilledDiskStep,
parseThreadDataJsonl,
selectForkHistoricalSteps,
type ThreadPauseGate,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
} from "./engine/fork-thread.js";
export { type GcResult, garbageCollectCas } from "./engine/gc.js";
export { createThreadPauseGate, type ThreadPauseGate } from "./engine/thread-pause-gate.js";
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
export { createExtract, type ExtractFn } from "./extract/extract-fn.js";
} from "./engine/index.js";
export {
createExtract,
type ExtractFn,
type LlmError,
llmErrorToCause,
llmExtract,
llmExtractWithRetry,
} from "./extract/llm-extract.js";
export { type ReactExtractArgs, reactExtract } from "./extract/react-extract.js";
export { getExtractProvider } from "./extract-provider.js";
type ReactExtractArgs,
reactExtract,
} from "./extract/index.js";
export {
type ExtractProviderConfig,
getRegisteredWorkflow,
listRegisteredWorkflowNames,
parseWorkflowRegistryYaml,
@@ -74,7 +78,7 @@ export {
type WorkflowRegistryFile,
workflowRegistryPath,
writeWorkflowRegistry,
} from "./registry/registry.js";
} from "./registry/index.js";
export {
type AgentBinding,
type AgentContext,
@@ -101,18 +105,19 @@ export {
} from "./types.js";
export {
CROCKFORD_BASE32_ALPHABET,
type CreateLoggerOptions,
createLogger,
decodeCrockfordBase32Bits,
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "./util/base32.js";
export {
type CreateLoggerOptions,
createLogger,
err,
generateUlid,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
type LogFn,
type LoggerSink,
} from "./util/logger.js";
export { err, ok, type Result } from "./util/result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./util/storage-root.js";
export { generateUlid } from "./util/ulid.js";
ok,
type Result,
} from "./util/index.js";
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
+18
View File
@@ -0,0 +1,18 @@
export {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
parseWorkflowRegistryYaml,
readWorkflowRegistry,
registerWorkflowVersion,
rollbackWorkflowToHistoryHash,
stringifyWorkflowRegistryYaml,
unregisterWorkflow,
workflowRegistryPath,
writeWorkflowRegistry,
} from "./registry.js";
export type {
WorkflowConfig,
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
} from "./types.js";
@@ -1,49 +1,107 @@
import { err, ok, type Result } from "../util/result.js";
import { type ProviderConfig, splitProviderModelRef } from "../config/index.js";
import { createLogger, err, ok, type Result } from "../util/index.js";
import type {
ExtractProviderConfig,
WorkflowConfig,
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
} from "./registry-types.js";
} from "./types.js";
function resolveRegistryApiKey(raw: string): Result<string, Error> {
const registryNormalizeLog = createLogger({ sink: { kind: "stderr" } });
function resolveRegistryApiKey(raw: string, ctx: string): Result<string, Error> {
if (raw.startsWith("env:")) {
const name = raw.slice("env:".length);
if (name === "") {
return err(new Error('config.extract.apiKey "env:" reference must name a variable'));
return err(new Error(`${ctx}: "env:" apiKey reference must name a variable`));
}
const value = process.env[name];
if (value === undefined) {
return err(new Error(`config.extract.apiKey: environment variable "${name}" is not set`));
return err(new Error(`${ctx}: environment variable "${name}" is not set`));
}
return ok(value);
}
return ok(raw);
}
function normalizeExtractProviderConfig(raw: unknown): Result<ExtractProviderConfig, Error> {
if (raw === null || typeof raw !== "object") {
return err(new Error('registry config must contain an "extract" mapping'));
function normalizeProviderEntry(name: string, entryRaw: unknown): Result<ProviderConfig, Error> {
if (name === "") {
return err(new Error("config.providers must not contain an empty provider name"));
}
const e = raw as Record<string, unknown>;
if (entryRaw === null || typeof entryRaw !== "object" || Array.isArray(entryRaw)) {
return err(new Error(`config.providers.${name} must be a mapping`));
}
const e = entryRaw as Record<string, unknown>;
const baseUrl = e.baseUrl;
const model = e.model;
const apiKeyRaw = e.apiKey;
if (typeof baseUrl !== "string" || baseUrl === "") {
return err(new Error("config.extract.baseUrl must be a non-empty string"));
}
if (typeof model !== "string" || model === "") {
return err(new Error("config.extract.model must be a non-empty string"));
return err(new Error(`config.providers.${name}.baseUrl must be a non-empty string`));
}
if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") {
return err(new Error("config.extract.apiKey must be a non-empty string"));
return err(new Error(`config.providers.${name}.apiKey must be a non-empty string`));
}
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw);
const apiKeyCtx = `config.providers.${name}.apiKey`;
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw, apiKeyCtx);
if (!apiKeyResult.ok) {
return apiKeyResult;
}
return ok({ baseUrl, model, apiKey: apiKeyResult.value });
return ok({ baseUrl, apiKey: apiKeyResult.value });
}
function normalizeProviders(raw: unknown): Result<Record<string, ProviderConfig>, Error> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return err(new Error('registry config must contain a "providers" mapping'));
}
const root = raw as Record<string, unknown>;
const providers: Record<string, ProviderConfig> = {};
for (const [name, entryRaw] of Object.entries(root)) {
const next = normalizeProviderEntry(name, entryRaw);
if (!next.ok) {
return next;
}
providers[name] = next.value;
}
return ok(providers);
}
function normalizeModels(
raw: unknown,
providers: Record<string, ProviderConfig>,
): Result<Record<string, string>, Error> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return err(new Error('registry config must contain a "models" mapping'));
}
const root = raw as Record<string, unknown>;
const models: Record<string, string> = {};
const providerKeys = new Set(Object.keys(providers));
for (const [scene, refRaw] of Object.entries(root)) {
if (scene === "") {
return err(new Error("config.models must not contain an empty scene name"));
}
if (typeof refRaw !== "string" || refRaw === "") {
return err(new Error(`config.models.${scene} must be a non-empty string (provider/model)`));
}
const ctx = `config.models.${scene}`;
const parsed = splitProviderModelRef(refRaw);
if (!parsed.ok) {
return err(new Error(`${ctx}: ${parsed.error}`));
}
if (!providerKeys.has(parsed.value.providerName)) {
return err(
new Error(
`${ctx}: unknown provider "${parsed.value.providerName}" (not listed under config.providers)`,
),
);
}
models[scene] = refRaw;
}
if (!Object.hasOwn(models, "default")) {
registryNormalizeLog(
"Z2KP9NWQ",
'registry config: models mapping has no "default" key; scenes without explicit model mappings may fail at resolveModel',
);
}
return ok(models);
}
function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
@@ -52,15 +110,24 @@ function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
}
const c = raw as Record<string, unknown>;
const maxDepth = c.maxDepth;
const extractRaw = c.extract;
const providersRaw = c.providers;
const modelsRaw = c.models;
if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) {
return err(new Error("config.maxDepth must be a non-negative integer"));
}
const extractResult = normalizeExtractProviderConfig(extractRaw);
if (!extractResult.ok) {
return extractResult;
const providersResult = normalizeProviders(providersRaw);
if (!providersResult.ok) {
return providersResult;
}
return ok({ maxDepth, extract: extractResult.value });
const modelsResult = normalizeModels(modelsRaw, providersResult.value);
if (!modelsResult.ok) {
return modelsResult;
}
return ok({
maxDepth,
providers: providersResult.value,
models: modelsResult.value,
});
}
export function normalizeWorkflowHistoryEntry(
+2 -14
View File
@@ -2,21 +2,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { parseDocument, stringify } from "yaml";
import { err, ok, type Result } from "../util/result.js";
import { err, ok, type Result } from "../util/index.js";
import { normalizeWorkflowRegistryRoot } from "./registry-normalize.js";
import type {
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
} from "./registry-types.js";
export type {
ExtractProviderConfig,
WorkflowConfig,
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
} from "./registry-types.js";
import type { WorkflowHistoryEntry, WorkflowRegistryEntry, WorkflowRegistryFile } from "./types.js";
export function workflowRegistryPath(storageRoot: string): string {
return join(storageRoot, "workflow.yaml");
@@ -1,3 +1,5 @@
import type { ProviderConfig } from "../config/index.js";
export type WorkflowHistoryEntry = {
hash: string;
timestamp: number;
@@ -9,16 +11,10 @@ export type WorkflowRegistryEntry = {
history: WorkflowHistoryEntry[];
};
/** LLM provider settings under `config.extract` in workflow.yaml (apiKey resolved after parse). */
export type ExtractProviderConfig = {
baseUrl: string;
model: string;
apiKey: string;
};
export type WorkflowConfig = {
maxDepth: number;
extract: ExtractProviderConfig;
providers: Record<string, ProviderConfig>;
models: Record<string, string>;
};
export type WorkflowRegistryFile = {
+6 -1
View File
@@ -1,6 +1,7 @@
import type * as z from "zod/v4";
import type { CasStore } from "./cas/cas.js";
import type { CasStore } from "./cas/index.js";
import type { ExtractFn } from "./extract/types.js";
/** Sentinel values for automaton control flow. */
export const START = "__start__" as const;
@@ -54,6 +55,10 @@ export type WorkflowFnOptions = {
depth: number;
/** Global CAS store for Merkle content blobs (role step bodies). */
cas: CasStore;
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
extract: ExtractFn;
/** Provider for `extractMode: "react"` roles; same backing config as `extract`. */
llmProvider: LlmProvider | null;
};
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
+2 -1
View File
@@ -1,4 +1,5 @@
import { err, ok, type Result } from "./result.js";
import { err, ok } from "./result.js";
import type { Result } from "./types.js";
/** Crockford Base32 alphabet (no I, L, O, U) — exactly 32 symbols. */
export const CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+13
View File
@@ -0,0 +1,13 @@
export {
CROCKFORD_BASE32_ALPHABET,
decodeCrockfordBase32Bits,
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "./base32.js";
export { createLogger } from "./logger.js";
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
export { err, ok } from "./result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
export type { CreateLoggerOptions, LogFn, LoggerSink, Result } from "./types.js";
export { generateUlid } from "./ulid.js";
+1 -8
View File
@@ -1,6 +1,7 @@
import { appendFileSync } from "node:fs";
import { CROCKFORD_BASE32_ALPHABET } from "./base32.js";
import type { CreateLoggerOptions, LogFn } from "./types.js";
const TAG_LENGTH = 8;
@@ -22,14 +23,6 @@ function assertValidLogTag(tag: string): void {
}
}
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
export type CreateLoggerOptions = {
sink: LoggerSink;
};
export type LogFn = (tag: string, content: string) => void;
/** Append one JSONL log record: `{ tag, content, timestamp }` per RFC-001. */
export function createLogger(options: CreateLoggerOptions): LogFn {
if (options.sink.kind === "stderr") {
+1 -1
View File
@@ -1,4 +1,4 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
import type { Result } from "./types.js";
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
+9
View File
@@ -0,0 +1,9 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
export type CreateLoggerOptions = {
sink: LoggerSink;
};
export type LogFn = (tag: string, content: string) => void;
+23 -9
View File
@@ -1,14 +1,27 @@
import { join } from "node:path";
import { extractBundleExports } from "./bundle/extract-bundle-exports.js";
import { createCasStore } from "./cas/cas.js";
import { type ExecuteThreadIo, executeThread } from "./engine/engine.js";
import { getWorkflowAsAgentMaxDepth } from "./extract-provider.js";
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry/registry.js";
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 } from "./util/logger.js";
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./util/storage-root.js";
import { generateUlid } from "./util/ulid.js";
import {
createLogger,
generateUlid,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
} from "./util/index.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
if (config === null) {
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
}
return config.maxDepth;
}
export type WorkflowAsAgentOptions = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
@@ -40,7 +53,7 @@ export function workflowAsAgent(
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
}
const maxDepth = getWorkflowAsAgentMaxDepth(registryResult.value.config);
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
}
@@ -88,6 +101,7 @@ export function workflowAsAgent(
awaitAfterEachYield: async () => {},
forkSourceThreadId: ctx.threadId,
prefilledDiskSteps: null,
storageRoot,
},
io,
logger,