diff --git a/packages/cli-workflow/__tests__/commands.test.ts b/packages/cli-workflow/__tests__/commands.test.ts index 2041053..4800fb3 100644 --- a/packages/cli-workflow/__tests__/commands.test.ts +++ b/packages/cli-workflow/__tests__/commands.test.ts @@ -3,13 +3,9 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - createContentMerkleNode, - getGlobalCasDir, - getRegisteredWorkflow, - readWorkflowRegistry, - serializeMerkleNode, -} from "@uncaged/workflow"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; +import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas"; +import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register"; import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js"; import { cmdAdd, @@ -25,7 +21,7 @@ import { addCliArgs } from "./bundle-fixture.js"; const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} }; `; -const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow"; +const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas"; `; function casStoredForm(raw: string): string { diff --git a/packages/cli-workflow/__tests__/fork-cli.test.ts b/packages/cli-workflow/__tests__/fork-cli.test.ts index 9a0f385..ac2e053 100644 --- a/packages/cli-workflow/__tests__/fork-cli.test.ts +++ b/packages/cli-workflow/__tests__/fork-cli.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow"; +import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; import { cmdFork, cmdRun } from "../src/commands/thread/index.js"; import { cmdAdd } from "../src/commands/workflow/index.js"; import { pathExists } from "../src/fs-utils.js"; @@ -10,7 +11,7 @@ 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"; +const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas"; export const descriptor = { description: "fork-cli", diff --git a/packages/cli-workflow/__tests__/gc-cli.test.ts b/packages/cli-workflow/__tests__/gc-cli.test.ts index c5e7d2a..49a0654 100644 --- a/packages/cli-workflow/__tests__/gc-cli.test.ts +++ b/packages/cli-workflow/__tests__/gc-cli.test.ts @@ -4,12 +4,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { - createCasStore, - garbageCollectCas, - getGlobalCasDir, - putContentMerkleNode, -} from "@uncaged/workflow"; +import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; +import { garbageCollectCas } from "@uncaged/workflow-execute"; import { cmdThreadRemove } from "../src/commands/thread/index.js"; import { pathExists } from "../src/fs-utils.js"; diff --git a/packages/cli-workflow/__tests__/init-template.test.ts b/packages/cli-workflow/__tests__/init-template.test.ts index c1423e9..5190da8 100644 --- a/packages/cli-workflow/__tests__/init-template.test.ts +++ b/packages/cli-workflow/__tests__/init-template.test.ts @@ -50,7 +50,6 @@ describe("init template", () => { dependencies: Record; }; expect(pkg.type).toBe("module"); - expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined(); expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined(); expect(pkg.dependencies.zod).toBeDefined(); expect(pkg.name).toContain("review-pr"); diff --git a/packages/cli-workflow/__tests__/init-workspace.test.ts b/packages/cli-workflow/__tests__/init-workspace.test.ts index 28e8265..93d924c 100644 --- a/packages/cli-workflow/__tests__/init-workspace.test.ts +++ b/packages/cli-workflow/__tests__/init-workspace.test.ts @@ -46,7 +46,7 @@ describe("init workspace", () => { dependencies: Record; }; expect(wfPkg.type).toBe("module"); - expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined(); + expect(wfPkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined(); expect(wfPkg.dependencies.zod).toBeDefined(); const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as { diff --git a/packages/cli-workflow/__tests__/live.test.ts b/packages/cli-workflow/__tests__/live.test.ts index 5c3fa19..8f0b4ae 100644 --- a/packages/cli-workflow/__tests__/live.test.ts +++ b/packages/cli-workflow/__tests__/live.test.ts @@ -5,7 +5,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { createCasStore, getGlobalCasDir, putContentMerkleNode } from "@uncaged/workflow"; +import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; import { formatLiveDebugLine, diff --git a/packages/cli-workflow/__tests__/serve.test.ts b/packages/cli-workflow/__tests__/serve.test.ts index 155f5de..3910a8a 100644 --- a/packages/cli-workflow/__tests__/serve.test.ts +++ b/packages/cli-workflow/__tests__/serve.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow"; +import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas"; import { createApp } from "../src/commands/serve/app.js"; diff --git a/packages/cli-workflow/__tests__/storage-env.test.ts b/packages/cli-workflow/__tests__/storage-env.test.ts index 518ce8c..d4d5f60 100644 --- a/packages/cli-workflow/__tests__/storage-env.test.ts +++ b/packages/cli-workflow/__tests__/storage-env.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow"; +import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util"; import { resolveWorkflowStorageRoot } from "../src/storage-env.js"; describe("resolveWorkflowStorageRoot", () => { diff --git a/packages/cli-workflow/__tests__/thread-cli.test.ts b/packages/cli-workflow/__tests__/thread-cli.test.ts index 715508b..ef13120 100644 --- a/packages/cli-workflow/__tests__/thread-cli.test.ts +++ b/packages/cli-workflow/__tests__/thread-cli.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { getGlobalCasDir } from "@uncaged/workflow"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; import { cmdCasPut } from "../src/commands/cas/index.js"; import { cmdKill, @@ -21,7 +21,7 @@ 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"; +const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas"; `; const threadFixtureDescriptor = `export const descriptor = { diff --git a/packages/cli-workflow/package.json b/packages/cli-workflow/package.json index c0bb241..6579618 100644 --- a/packages/cli-workflow/package.json +++ b/packages/cli-workflow/package.json @@ -6,8 +6,12 @@ "uncaged-workflow": "src/cli.ts" }, "dependencies": { + "@uncaged/workflow-protocol": "workspace:*", + "@uncaged/workflow-util": "workspace:*", + "@uncaged/workflow-cas": "workspace:*", + "@uncaged/workflow-execute": "workspace:*", + "@uncaged/workflow-register": "workspace:*", "@uncaged/workflow-runtime": "workspace:*", - "@uncaged/workflow": "workspace:*", "hono": "^4.12.18", "yaml": "^2.8.4" }, diff --git a/packages/cli-workflow/src/bundle-store.ts b/packages/cli-workflow/src/bundle-store.ts index 83d9af2..1305c36 100644 --- a/packages/cli-workflow/src/bundle-store.ts +++ b/packages/cli-workflow/src/bundle-store.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { pathExists } from "./fs-utils.js"; diff --git a/packages/cli-workflow/src/commands/cas/gc.ts b/packages/cli-workflow/src/commands/cas/gc.ts index cf1aab9..e00b41f 100644 --- a/packages/cli-workflow/src/commands/cas/gc.ts +++ b/packages/cli-workflow/src/commands/cas/gc.ts @@ -1,4 +1,5 @@ -import { type GcResult, garbageCollectCas, type Result } from "@uncaged/workflow"; +import type { Result } from "@uncaged/workflow-protocol"; +import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute"; export async function cmdGc(storageRoot: string): Promise> { return garbageCollectCas(storageRoot); diff --git a/packages/cli-workflow/src/commands/cas/get.ts b/packages/cli-workflow/src/commands/cas/get.ts index 2ecdccd..7eb9c4c 100644 --- a/packages/cli-workflow/src/commands/cas/get.ts +++ b/packages/cli-workflow/src/commands/cas/get.ts @@ -1,4 +1,6 @@ -import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; +import { createCasStore } from "@uncaged/workflow-cas"; export async function cmdCasGet( storageRoot: string, diff --git a/packages/cli-workflow/src/commands/cas/list.ts b/packages/cli-workflow/src/commands/cas/list.ts index fbfafad..cbe5e59 100644 --- a/packages/cli-workflow/src/commands/cas/list.ts +++ b/packages/cli-workflow/src/commands/cas/list.ts @@ -1,4 +1,6 @@ -import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow"; +import { ok, type Result } from "@uncaged/workflow-protocol"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; +import { createCasStore } from "@uncaged/workflow-cas"; export async function cmdCasList(storageRoot: string): Promise> { const cas = createCasStore(getGlobalCasDir(storageRoot)); diff --git a/packages/cli-workflow/src/commands/cas/put.ts b/packages/cli-workflow/src/commands/cas/put.ts index a6745e5..65fe431 100644 --- a/packages/cli-workflow/src/commands/cas/put.ts +++ b/packages/cli-workflow/src/commands/cas/put.ts @@ -1,4 +1,6 @@ -import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow"; +import { ok, type Result } from "@uncaged/workflow-protocol"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; +import { createCasStore } from "@uncaged/workflow-cas"; export async function cmdCasPut( storageRoot: string, diff --git a/packages/cli-workflow/src/commands/cas/rm.ts b/packages/cli-workflow/src/commands/cas/rm.ts index aec6980..9140e12 100644 --- a/packages/cli-workflow/src/commands/cas/rm.ts +++ b/packages/cli-workflow/src/commands/cas/rm.ts @@ -1,4 +1,6 @@ -import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow"; +import { ok, type Result } from "@uncaged/workflow-protocol"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; +import { createCasStore } from "@uncaged/workflow-cas"; export async function cmdCasRm(storageRoot: string, hash: string): Promise> { const cas = createCasStore(getGlobalCasDir(storageRoot)); diff --git a/packages/cli-workflow/src/commands/init/template.ts b/packages/cli-workflow/src/commands/init/template.ts index d788343..f3a6c38 100644 --- a/packages/cli-workflow/src/commands/init/template.ts +++ b/packages/cli-workflow/src/commands/init/template.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { pathExists } from "../../fs-utils.js"; diff --git a/packages/cli-workflow/src/commands/init/templates.ts b/packages/cli-workflow/src/commands/init/templates.ts index 5ccebc1..65a280b 100644 --- a/packages/cli-workflow/src/commands/init/templates.ts +++ b/packages/cli-workflow/src/commands/init/templates.ts @@ -6,7 +6,6 @@ export function templatePackageJson(templateName: string): string { private: true, type: "module", dependencies: { - "@uncaged/workflow": "^0.1.0", "@uncaged/workflow-runtime": "^0.1.0", zod: "^4.0.0", }, diff --git a/packages/cli-workflow/src/commands/init/validate.ts b/packages/cli-workflow/src/commands/init/validate.ts index e39ad19..55dbdb3 100644 --- a/packages/cli-workflow/src/commands/init/validate.ts +++ b/packages/cli-workflow/src/commands/init/validate.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; /** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */ export function validateWorkspaceSegment(name: string): Result { diff --git a/packages/cli-workflow/src/commands/init/workspace.ts b/packages/cli-workflow/src/commands/init/workspace.ts index ba6acae..b05aa1b 100644 --- a/packages/cli-workflow/src/commands/init/workspace.ts +++ b/packages/cli-workflow/src/commands/init/workspace.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { pathExists } from "../../fs-utils.js"; import type { CmdInitWorkspaceSuccess } from "./types.js"; @@ -28,7 +28,7 @@ function workflowsPackageJson(): string { private: true, type: "module", dependencies: { - "@uncaged/workflow": "^0.1.0", + "@uncaged/workflow-runtime": "^0.1.0", zod: "^4.0.0", }, }, diff --git a/packages/cli-workflow/src/commands/serve/routes-cas.ts b/packages/cli-workflow/src/commands/serve/routes-cas.ts index 6ad827a..f796a18 100644 --- a/packages/cli-workflow/src/commands/serve/routes-cas.ts +++ b/packages/cli-workflow/src/commands/serve/routes-cas.ts @@ -1,4 +1,6 @@ -import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; +import { createCasStore } from "@uncaged/workflow-cas"; +import { garbageCollectCas } from "@uncaged/workflow-execute"; import { Hono } from "hono"; export function createCasRoutes(storageRoot: string): Hono { diff --git a/packages/cli-workflow/src/commands/serve/routes-workflow.ts b/packages/cli-workflow/src/commands/serve/routes-workflow.ts index ea728c3..d06a39d 100644 --- a/packages/cli-workflow/src/commands/serve/routes-workflow.ts +++ b/packages/cli-workflow/src/commands/serve/routes-workflow.ts @@ -2,7 +2,7 @@ import { getRegisteredWorkflow, listRegisteredWorkflowNames, readWorkflowRegistry, -} from "@uncaged/workflow"; +} from "@uncaged/workflow-register"; import { Hono } from "hono"; export function createWorkflowRoutes(storageRoot: string): Hono { diff --git a/packages/cli-workflow/src/commands/serve/serve.ts b/packages/cli-workflow/src/commands/serve/serve.ts index 8609efb..1d894fd 100644 --- a/packages/cli-workflow/src/commands/serve/serve.ts +++ b/packages/cli-workflow/src/commands/serve/serve.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { serve } from "bun"; import { printCliLine } from "../../cli-output.js"; diff --git a/packages/cli-workflow/src/commands/thread/control.ts b/packages/cli-workflow/src/commands/thread/control.ts index 3aa85b8..feba302 100644 --- a/packages/cli-workflow/src/commands/thread/control.ts +++ b/packages/cli-workflow/src/commands/thread/control.ts @@ -1,4 +1,4 @@ -import type { Result } from "@uncaged/workflow"; +import type { Result } from "@uncaged/workflow-protocol"; import { readWorkerCtl, diff --git a/packages/cli-workflow/src/commands/thread/fork-argv.ts b/packages/cli-workflow/src/commands/thread/fork-argv.ts index f85b2a1..a3bf6f7 100644 --- a/packages/cli-workflow/src/commands/thread/fork-argv.ts +++ b/packages/cli-workflow/src/commands/thread/fork-argv.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import type { ParsedForkArgv } from "./types.js"; diff --git a/packages/cli-workflow/src/commands/thread/fork.ts b/packages/cli-workflow/src/commands/thread/fork.ts index 8003d66..0d8748c 100644 --- a/packages/cli-workflow/src/commands/thread/fork.ts +++ b/packages/cli-workflow/src/commands/thread/fork.ts @@ -1,6 +1,8 @@ import { join } from "node:path"; -import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; +import { generateUlid } from "@uncaged/workflow-util"; +import { buildForkPlan } from "@uncaged/workflow-execute"; import { pathExists, readTextFileIfExists } from "../../fs-utils.js"; import { resolveThreadDataPath } from "../../thread-scan.js"; diff --git a/packages/cli-workflow/src/commands/thread/list.ts b/packages/cli-workflow/src/commands/thread/list.ts index 214d532..8816210 100644 --- a/packages/cli-workflow/src/commands/thread/list.ts +++ b/packages/cli-workflow/src/commands/thread/list.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { listHistoricalThreads } from "../../thread-scan.js"; import { validateCliWorkflowName } from "../../workflow-name.js"; diff --git a/packages/cli-workflow/src/commands/thread/live.ts b/packages/cli-workflow/src/commands/thread/live.ts index bb9b684..36ca209 100644 --- a/packages/cli-workflow/src/commands/thread/live.ts +++ b/packages/cli-workflow/src/commands/thread/live.ts @@ -2,15 +2,10 @@ import { watch } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { - type CasStore, - createCasStore, - getContentMerklePayload, - getGlobalCasDir, - tryParseRoleStepRecord, - tryParseWorkflowResultRecord, -} from "@uncaged/workflow"; -import type { WorkflowCompletion } from "@uncaged/workflow-runtime"; +import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol"; +import { getGlobalCasDir } from "@uncaged/workflow-util"; +import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas"; +import { tryParseRoleStepRecord, tryParseWorkflowResultRecord } from "@uncaged/workflow-execute"; import { dimGreyLine, highlightLiveRole } from "../../cli-color.js"; import { printCliError, printCliLine } from "../../cli-output.js"; diff --git a/packages/cli-workflow/src/commands/thread/rm.ts b/packages/cli-workflow/src/commands/thread/rm.ts index 2e88e12..3cf94e1 100644 --- a/packages/cli-workflow/src/commands/thread/rm.ts +++ b/packages/cli-workflow/src/commands/thread/rm.ts @@ -1,7 +1,8 @@ import { unlink } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; +import { garbageCollectCas } from "@uncaged/workflow-execute"; import { resolveThreadDataPath } from "../../thread-scan.js"; diff --git a/packages/cli-workflow/src/commands/thread/run.ts b/packages/cli-workflow/src/commands/thread/run.ts index 7af87ce..4008833 100644 --- a/packages/cli-workflow/src/commands/thread/run.ts +++ b/packages/cli-workflow/src/commands/thread/run.ts @@ -1,13 +1,8 @@ import { join } from "node:path"; -import { - err, - generateUlid, - getRegisteredWorkflow, - ok, - type Result, - readWorkflowRegistry, -} from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; +import { generateUlid } from "@uncaged/workflow-util"; +import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register"; import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js"; import { validateCliWorkflowName } from "../../workflow-name.js"; diff --git a/packages/cli-workflow/src/commands/thread/show.ts b/packages/cli-workflow/src/commands/thread/show.ts index 460ca5f..3669674 100644 --- a/packages/cli-workflow/src/commands/thread/show.ts +++ b/packages/cli-workflow/src/commands/thread/show.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { readTextFileIfExists } from "../../fs-utils.js"; import { resolveThreadDataPath } from "../../thread-scan.js"; diff --git a/packages/cli-workflow/src/commands/workflow/add-argv.ts b/packages/cli-workflow/src/commands/workflow/add-argv.ts index d6009f2..91167c0 100644 --- a/packages/cli-workflow/src/commands/workflow/add-argv.ts +++ b/packages/cli-workflow/src/commands/workflow/add-argv.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import type { ParsedAddArgv } from "./types.js"; diff --git a/packages/cli-workflow/src/commands/workflow/add.ts b/packages/cli-workflow/src/commands/workflow/add.ts index bd736cf..2b866d4 100644 --- a/packages/cli-workflow/src/commands/workflow/add.ts +++ b/packages/cli-workflow/src/commands/workflow/add.ts @@ -1,18 +1,16 @@ import { readFile, stat } from "node:fs/promises"; import { basename, resolve } from "node:path"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; +import { hashWorkflowBundleBytes } from "@uncaged/workflow-cas"; import { - err, extractBundleExports, - hashWorkflowBundleBytes, - ok, - type Result, readWorkflowRegistry, registerWorkflowVersion, stringifyWorkflowDescriptor, validateWorkflowBundle, writeWorkflowRegistry, -} from "@uncaged/workflow"; +} from "@uncaged/workflow-register"; import { storeWorkflowBundleArtifacts } from "../../bundle-store.js"; import { validateCliWorkflowName } from "../../workflow-name.js"; diff --git a/packages/cli-workflow/src/commands/workflow/history.ts b/packages/cli-workflow/src/commands/workflow/history.ts index ecf41d1..9d94fde 100644 --- a/packages/cli-workflow/src/commands/workflow/history.ts +++ b/packages/cli-workflow/src/commands/workflow/history.ts @@ -1,10 +1,5 @@ -import { - err, - getRegisteredWorkflow, - ok, - type Result, - readWorkflowRegistry, -} from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; +import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register"; import { validateCliWorkflowName } from "../../workflow-name.js"; diff --git a/packages/cli-workflow/src/commands/workflow/list.ts b/packages/cli-workflow/src/commands/workflow/list.ts index 82d60d5..7c1a21c 100644 --- a/packages/cli-workflow/src/commands/workflow/list.ts +++ b/packages/cli-workflow/src/commands/workflow/list.ts @@ -1,11 +1,9 @@ +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { - err, listRegisteredWorkflowNames, - ok, - type Result, readWorkflowRegistry, type WorkflowRegistryFile, -} from "@uncaged/workflow"; +} from "@uncaged/workflow-register"; export async function cmdList(storageRoot: string): Promise> { const reg = await readWorkflowRegistry(storageRoot); diff --git a/packages/cli-workflow/src/commands/workflow/rm.ts b/packages/cli-workflow/src/commands/workflow/rm.ts index 0a1a7cf..5906bc4 100644 --- a/packages/cli-workflow/src/commands/workflow/rm.ts +++ b/packages/cli-workflow/src/commands/workflow/rm.ts @@ -1,11 +1,9 @@ +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { - err, - ok, - type Result, readWorkflowRegistry, unregisterWorkflow, writeWorkflowRegistry, -} from "@uncaged/workflow"; +} from "@uncaged/workflow-register"; import { validateCliWorkflowName } from "../../workflow-name.js"; diff --git a/packages/cli-workflow/src/commands/workflow/rollback.ts b/packages/cli-workflow/src/commands/workflow/rollback.ts index 77d4852..dd9c6ea 100644 --- a/packages/cli-workflow/src/commands/workflow/rollback.ts +++ b/packages/cli-workflow/src/commands/workflow/rollback.ts @@ -1,14 +1,12 @@ import { join } from "node:path"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { - err, getRegisteredWorkflow, - ok, - type Result, readWorkflowRegistry, rollbackWorkflowToHistoryHash, writeWorkflowRegistry, -} from "@uncaged/workflow"; +} from "@uncaged/workflow-register"; import { pathExists } from "../../fs-utils.js"; import { validateCliWorkflowName } from "../../workflow-name.js"; diff --git a/packages/cli-workflow/src/commands/workflow/show.ts b/packages/cli-workflow/src/commands/workflow/show.ts index 5b4543a..128335f 100644 --- a/packages/cli-workflow/src/commands/workflow/show.ts +++ b/packages/cli-workflow/src/commands/workflow/show.ts @@ -1,11 +1,9 @@ +import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { - err, getRegisteredWorkflow, - ok, - type Result, readWorkflowRegistry, type WorkflowRegistryEntry, -} from "@uncaged/workflow"; +} from "@uncaged/workflow-register"; import { stringify } from "yaml"; import { validateCliWorkflowName } from "../../workflow-name.js"; diff --git a/packages/cli-workflow/src/live-argv.ts b/packages/cli-workflow/src/live-argv.ts index 9119acc..78735db 100644 --- a/packages/cli-workflow/src/live-argv.ts +++ b/packages/cli-workflow/src/live-argv.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; export type ParsedLiveArgv = { threadId: string | null; diff --git a/packages/cli-workflow/src/run-argv.ts b/packages/cli-workflow/src/run-argv.ts index 1107cfe..11af542 100644 --- a/packages/cli-workflow/src/run-argv.ts +++ b/packages/cli-workflow/src/run-argv.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; export type ParsedRunArgv = { name: string; diff --git a/packages/cli-workflow/src/storage-env.ts b/packages/cli-workflow/src/storage-env.ts index 08d39ea..100a0d6 100644 --- a/packages/cli-workflow/src/storage-env.ts +++ b/packages/cli-workflow/src/storage-env.ts @@ -1,4 +1,4 @@ -import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow"; +import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util"; /** * Resolve storage root with env var override support. diff --git a/packages/cli-workflow/src/worker-spawn.ts b/packages/cli-workflow/src/worker-spawn.ts index 35456e0..b8c6836 100644 --- a/packages/cli-workflow/src/worker-spawn.ts +++ b/packages/cli-workflow/src/worker-spawn.ts @@ -3,7 +3,8 @@ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises"; import { createConnection } from "node:net"; import { join } from "node:path"; -import { err, getWorkerHostScriptPath, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; +import { getWorkerHostScriptPath } from "@uncaged/workflow-execute"; import { pathExists, readTextFileIfExists } from "./fs-utils.js"; diff --git a/packages/cli-workflow/src/workflow-name.ts b/packages/cli-workflow/src/workflow-name.ts index ae27666..2e0c4f4 100644 --- a/packages/cli-workflow/src/workflow-name.ts +++ b/packages/cli-workflow/src/workflow-name.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "@uncaged/workflow"; +import { err, ok, type Result } from "@uncaged/workflow-protocol"; const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; diff --git a/packages/workflow-execute/package.json b/packages/workflow-execute/package.json new file mode 100644 index 0000000..90d28fe --- /dev/null +++ b/packages/workflow-execute/package.json @@ -0,0 +1,25 @@ +{ + "name": "@uncaged/workflow-execute", + "version": "0.2.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@uncaged/workflow-protocol": "workspace:*", + "@uncaged/workflow-runtime": "workspace:*", + "@uncaged/workflow-util": "workspace:*", + "@uncaged/workflow-cas": "workspace:*", + "@uncaged/workflow-reactor": "workspace:*", + "@uncaged/workflow-register": "workspace:*", + "yaml": "^2.7.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "devDependencies": { + "zod": "^4.0.0" + } +} diff --git a/packages/workflow-execute/src/engine/create-workflow.ts b/packages/workflow-execute/src/engine/create-workflow.ts new file mode 100644 index 0000000..b7fc2a7 --- /dev/null +++ b/packages/workflow-execute/src/engine/create-workflow.ts @@ -0,0 +1,8 @@ +/** + * Re-export of {@link createWorkflow} from `@uncaged/workflow-runtime`. + * + * The runtime's `createWorkflow` already binds role definitions + agents to a workflow loop + * and delegates structured meta extraction to `WorkflowRuntime.extract`, which the engine + * supplies (resolved from the `extract` scene in workflow.yaml). + */ +export { createWorkflow } from "@uncaged/workflow-runtime"; diff --git a/packages/workflow-execute/src/engine/engine.ts b/packages/workflow-execute/src/engine/engine.ts new file mode 100644 index 0000000..16eafc4 --- /dev/null +++ b/packages/workflow-execute/src/engine/engine.ts @@ -0,0 +1,415 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { + LlmProvider, + RoleOutput, + ThreadContext, + WorkflowCompletion, + WorkflowFn, + WorkflowResult, + WorkflowRuntime, +} from "@uncaged/workflow-runtime"; +import { START } from "@uncaged/workflow-runtime"; +import { + type CasStore, + getContentMerklePayload, + putStepMerkleNode, + putThreadMerkleNode, +} from "@uncaged/workflow-cas"; +import { resolveModel } from "@uncaged/workflow-register"; +import { createExtract } from "../extract/index.js"; +import { readWorkflowRegistry, type WorkflowConfig } from "@uncaged/workflow-register"; +import { err, type LogFn, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util"; + +import { runSupervisor } from "./supervisor.js"; +import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js"; + +async function resolveEngineRegistryRuntime( + storageRoot: string, + cas: CasStore, +): Promise< + Result< + { + extract: ReturnType; + workflowConfig: WorkflowConfig; + }, + 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, { cas }), workflowConfig: cfg }); +} + +async function appendDataLine(path: string, record: unknown): Promise { + const line = `${JSON.stringify(record)}\n`; + await appendFile(path, line, "utf8"); +} + +async function finalizeThreadResult(params: { + cas: CasStore; + workflowName: string; + threadId: string; + stepMerkleHashes: readonly string[]; + completion: WorkflowCompletion; +}): Promise { + const rootHash = await putThreadMerkleNode( + params.cas, + { + workflow: params.workflowName, + threadId: params.threadId, + result: { + returnCode: params.completion.returnCode, + summary: params.completion.summary, + }, + }, + params.stepMerkleHashes, + ); + return { + returnCode: params.completion.returnCode, + summary: params.completion.summary, + rootHash, + }; +} + +async function finalizeAbortedThread(params: { + cas: CasStore; + workflowName: string; + threadId: string; + stepMerkleHashes: string[]; + logger: LogFn; + abortLogTag: string; +}): Promise { + params.logger(params.abortLogTag, `thread ${params.threadId} aborted`); + return finalizeThreadResult({ + cas: params.cas, + workflowName: params.workflowName, + threadId: params.threadId, + stepMerkleHashes: params.stepMerkleHashes, + completion: { returnCode: 130, summary: "thread aborted" }, + }); +} + +async function maybeSupervisorHaltsThread(params: { + workflowConfig: WorkflowConfig; + thread: ThreadContext; + written: number; + recentSupervisorSteps: readonly { role: string; summary: string }[]; + logger: LogFn; + threadId: string; + cas: CasStore; + workflowName: string; + stepMerkleHashes: string[]; +}): Promise { + const interval = params.workflowConfig.supervisorInterval; + if (interval <= 0 || params.written % interval !== 0) { + return null; + } + const sup = await runSupervisor({ + config: params.workflowConfig, + prompt: params.thread.start.content, + recentSteps: params.recentSupervisorSteps, + logger: params.logger, + }); + if (!sup.ok) { + params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`); + return null; + } + if (sup.value !== "stop") { + return null; + } + params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`); + return finalizeThreadResult({ + cas: params.cas, + workflowName: params.workflowName, + threadId: params.threadId, + stepMerkleHashes: params.stepMerkleHashes, + completion: { returnCode: 0, summary: "completed: supervisor stopped thread" }, + }); +} + +async function driveWorkflowGenerator(params: { + fn: WorkflowFn; + workflowName: string; + workflowConfig: WorkflowConfig; + thread: ThreadContext; + runtime: WorkflowRuntime; + executeOptions: ExecuteThreadOptions; + dataJsonlPath: string; + threadId: string; + logger: LogFn; + cas: CasStore; + stepMerkleHashes: string[]; +}): Promise { + const { + fn, + workflowName, + workflowConfig, + thread, + runtime, + executeOptions, + dataJsonlPath, + threadId, + logger, + cas, + stepMerkleHashes, + } = params; + const gen = fn(thread, runtime); + let written = 0; + const recentSupervisorSteps: { role: string; summary: string }[] = thread.steps.map((s) => ({ + role: s.role, + summary: JSON.stringify(s.meta), + })); + + while (true) { + if (executeOptions.signal.aborted) { + return await finalizeAbortedThread({ + cas, + workflowName, + threadId, + stepMerkleHashes, + logger, + abortLogTag: "V8JX4NP2", + }); + } + + if (written >= executeOptions.maxRounds) { + logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`); + return await finalizeThreadResult({ + cas, + workflowName, + threadId, + stepMerkleHashes, + completion: { + returnCode: 0, + summary: `completed: reached maxRounds (${executeOptions.maxRounds})`, + }, + }); + } + + const iterResult = await gen.next(); + + if (iterResult.done) { + logger("F3HN8QKP", `thread ${threadId} generator finished`); + const completion = iterResult.value; + return await finalizeThreadResult({ + cas, + workflowName, + threadId, + stepMerkleHashes, + completion, + }); + } + + written++; + const step = iterResult.value; + const resolved = await getContentMerklePayload(cas, step.contentHash); + if (resolved === null) { + throw new Error( + `role step ${step.role}: CAS blob missing for contentHash ${step.contentHash}`, + ); + } + const ts = Date.now(); + await appendDataLine(dataJsonlPath, { + role: step.role, + contentHash: step.contentHash, + meta: step.meta, + refs: normalizeRefsField(step.refs), + timestamp: ts, + }); + + const stepNodeHash = await putStepMerkleNode( + cas, + { role: step.role, meta: step.meta }, + step.contentHash, + ); + stepMerkleHashes.push(stepNodeHash); + + logger("N7BW4YHQ", `thread ${threadId} wrote role ${step.role}`); + + recentSupervisorSteps.push({ + role: step.role, + summary: JSON.stringify(step.meta), + }); + + await Promise.race([ + executeOptions.awaitAfterEachYield(), + new Promise((resolve) => { + if (executeOptions.signal.aborted) { + resolve(); + return; + } + executeOptions.signal.addEventListener("abort", () => resolve(), { once: true }); + }), + ]); + + if (executeOptions.signal.aborted) { + return await finalizeAbortedThread({ + cas, + workflowName, + threadId, + stepMerkleHashes, + logger, + abortLogTag: "V8JX4NP4", + }); + } + + const supervised = await maybeSupervisorHaltsThread({ + workflowConfig, + thread, + written, + recentSupervisorSteps, + logger, + threadId, + cas, + workflowName, + stepMerkleHashes, + }); + if (supervised !== null) { + return supervised; + } + } +} + +/** + * Execute a workflow thread: drive the bundle's AsyncGenerator, RFC-001 `.data.jsonl` records, + * debug lines via `logger` to `.info.jsonl`. + */ +export async function executeThread( + fn: WorkflowFn, + workflowName: string, + input: { prompt: string; steps: RoleOutput[] }, + options: ExecuteThreadOptions, + io: ExecuteThreadIo, + logger: LogFn, +): Promise { + await mkdir(dirname(io.dataJsonlPath), { recursive: true }); + await mkdir(dirname(io.infoJsonlPath), { recursive: true }); + + const prefilled = options.prefilledDiskSteps; + if (prefilled !== null && prefilled.length !== input.steps.length) { + throw new Error( + `prefilledDiskSteps length (${prefilled.length}) must match input.steps length (${input.steps.length})`, + ); + } + + const nowMs = Date.now(); + const startRecord: Record = { + name: workflowName, + hash: io.hash, + threadId: io.threadId, + parameters: { + prompt: input.prompt, + options: { + maxRounds: options.maxRounds, + depth: options.depth, + }, + }, + timestamp: nowMs, + }; + if (options.forkSourceThreadId !== null) { + startRecord.forkFrom = { threadId: options.forkSourceThreadId }; + } + + await appendDataLine(io.dataJsonlPath, startRecord); + + logger("T9HQ2KHM", `thread ${io.threadId} started for workflow ${workflowName}`); + + const stepMerkleHashes: string[] = []; + + if (prefilled !== null) { + for (const row of prefilled) { + const prefilledPayload = await getContentMerklePayload(io.cas, row.contentHash); + if (prefilledPayload === null) { + throw new Error( + `prefilled step ${row.role}: CAS blob missing for contentHash ${row.contentHash}`, + ); + } + await appendDataLine(io.dataJsonlPath, { + role: row.role, + contentHash: row.contentHash, + meta: row.meta, + refs: normalizeRefsField(row.refs), + timestamp: row.timestamp, + }); + const stepNodeHash = await putStepMerkleNode( + io.cas, + { role: row.role, meta: row.meta }, + row.contentHash, + ); + stepMerkleHashes.push(stepNodeHash); + } + } + + if (options.maxRounds <= 0) { + logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`); + return await finalizeThreadResult({ + cas: io.cas, + workflowName, + threadId: io.threadId, + stepMerkleHashes, + completion: { + returnCode: 0, + summary: `completed: reached maxRounds (${options.maxRounds})`, + }, + }); + } + + const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas); + if (!registryRuntime.ok) { + throw new Error(registryRuntime.error); + } + + const thread: ThreadContext = { + threadId: io.threadId, + depth: options.depth, + start: { + role: START, + content: input.prompt, + meta: { maxRounds: options.maxRounds }, + timestamp: nowMs, + }, + steps: input.steps.map((out, i) => ({ + role: out.role, + contentHash: out.contentHash, + meta: out.meta, + refs: out.refs, + timestamp: prefilled?.[i]?.timestamp ?? nowMs + i, + })), + }; + + const runtime: WorkflowRuntime = { + cas: io.cas, + extract: registryRuntime.value.extract, + }; + + return await driveWorkflowGenerator({ + fn, + workflowName, + workflowConfig: registryRuntime.value.workflowConfig, + thread, + runtime, + executeOptions: options, + dataJsonlPath: io.dataJsonlPath, + threadId: io.threadId, + logger, + cas: io.cas, + stepMerkleHashes, + }); +} diff --git a/packages/workflow-execute/src/engine/fork-thread.ts b/packages/workflow-execute/src/engine/fork-thread.ts new file mode 100644 index 0000000..b564973 --- /dev/null +++ b/packages/workflow-execute/src/engine/fork-thread.ts @@ -0,0 +1,244 @@ +import type { WorkflowCompletion } from "@uncaged/workflow-runtime"; +import { err, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util"; + +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( + obj: Record, +): WorkflowCompletion | null { + if (obj.role !== undefined) { + return null; + } + const returnCode = obj.returnCode; + const summary = obj.summary; + if (typeof returnCode !== "number" || typeof summary !== "string") { + return null; + } + return { returnCode, summary }; +} + +export function tryParseRoleStepRecord(obj: Record): ForkHistoricalStep | null { + const role = obj.role; + const contentHash = obj.contentHash; + const meta = obj.meta; + const timestamp = obj.timestamp; + if (typeof role !== "string") { + return null; + } + if (typeof contentHash !== "string") { + return null; + } + if (meta === null || typeof meta !== "object") { + return null; + } + if (typeof timestamp !== "number") { + return null; + } + return { + role, + contentHash, + meta: meta as Record, + refs: normalizeRefsField(obj.refs), + timestamp, + }; +} + +function parseRoleLine( + obj: Record, + lineIndex: number, +): Result { + const parsed = tryParseRoleStepRecord(obj); + if (parsed === null) { + return err(`invalid role record at line ${lineIndex}`); + } + return ok(parsed); +} + +function parseStartRecordLine(firstLine: string): Result { + let startParsed: unknown; + try { + startParsed = JSON.parse(firstLine) as unknown; + } catch { + return err("invalid JSON on line 1 (start record)"); + } + if (startParsed === null || typeof startParsed !== "object") { + return err("invalid start record shape"); + } + const startRec = startParsed as Record; + const name = startRec.name; + const hash = startRec.hash; + const threadId = startRec.threadId; + const parameters = startRec.parameters; + if (typeof name !== "string" || typeof hash !== "string" || typeof threadId !== "string") { + return err("start record missing name, hash, or threadId"); + } + if (parameters === null || typeof parameters !== "object") { + return err("start record missing parameters"); + } + const paramsRec = parameters as Record; + const prompt = paramsRec.prompt; + const options = paramsRec.options; + if (typeof prompt !== "string") { + return err("start record missing parameters.prompt"); + } + if (options === null || typeof options !== "object") { + return err("start record missing parameters.options"); + } + const optRec = options as Record; + const maxRounds = optRec.maxRounds; + if (typeof maxRounds !== "number") { + return err("start record missing parameters.options.maxRounds"); + } + + const depthRaw = optRec.depth; + const depth = + typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0; + + return ok({ + workflowName: name, + hash, + threadId, + prompt, + maxRounds, + depth, + }); +} + +function parseFollowingRoleLines(lines: string[]): Result { + const roleSteps: ForkHistoricalStep[] = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) { + break; + } + let rec: unknown; + try { + rec = JSON.parse(line) as unknown; + } catch { + return err(`invalid JSON at line ${i + 1}`); + } + if (rec === null || typeof rec !== "object") { + return err(`invalid record at line ${i + 1}`); + } + const recObj = rec as Record; + const wf = tryParseWorkflowResultRecord(recObj); + if (wf !== null) { + if (i !== lines.length - 1) { + return err("WorkflowResult record must be the final line in `.data.jsonl`"); + } + break; + } + const parsed = parseRoleLine(recObj, i + 1); + if (!parsed.ok) { + return parsed; + } + roleSteps.push(parsed.value); + } + return ok(roleSteps); +} + +/** + * Parse RFC-001 `.data.jsonl`: line 1 start record, line 2+ role outputs. + */ +export function parseThreadDataJsonl(text: string): Result< + { + start: ParsedThreadStartRecord; + roleSteps: ForkHistoricalStep[]; + }, + string +> { + const lines = text + .split("\n") + .map((l) => l.trim()) + .filter((l) => l !== ""); + if (lines.length === 0) { + return err("thread data is empty"); + } + + const firstLine = lines[0]; + if (firstLine === undefined) { + return err("thread data is empty"); + } + + const start = parseStartRecordLine(firstLine); + if (!start.ok) { + return start; + } + + const roleSteps = parseFollowingRoleLines(lines); + if (!roleSteps.ok) { + return roleSteps; + } + + return ok({ + start: start.value, + roleSteps: roleSteps.value, + }); +} + +function orderedUniqueRoles(roleSteps: ForkHistoricalStep[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const s of roleSteps) { + if (!seen.has(s.role)) { + seen.add(s.role); + out.push(s.role); + } + } + return out; +} + +/** + * Select historical steps for a fork: + * - `fromRole === null`: drop the last step (retry the last role). + * - `fromRole !== null`: keep steps through the first occurrence of that role (inclusive). + */ +export function selectForkHistoricalSteps( + roleSteps: ForkHistoricalStep[], + fromRole: string | null, +): Result { + if (roleSteps.length === 0) { + return err("thread has no completed role steps to fork from"); + } + + if (fromRole === null) { + if (roleSteps.length === 1) { + return ok([]); + } + return ok(roleSteps.slice(0, -1)); + } + + const idx = roleSteps.findIndex((s) => s.role === fromRole); + if (idx < 0) { + const available = orderedUniqueRoles(roleSteps); + return err(`role not found in thread: ${fromRole} (available: ${available.join(", ")})`); + } + return ok(roleSteps.slice(0, idx + 1)); +} + +/** + * Read `.data.jsonl` text and compute fork payload for the worker `run` command. + */ +export function buildForkPlan( + dataJsonlText: string, + fromRole: string | null, +): Result { + const parsed = parseThreadDataJsonl(dataJsonlText); + if (!parsed.ok) { + return parsed; + } + const selected = selectForkHistoricalSteps(parsed.value.roleSteps, fromRole); + if (!selected.ok) { + return selected; + } + const { start } = parsed.value; + return ok({ + workflowName: start.workflowName, + hash: start.hash, + sourceThreadId: start.threadId, + prompt: start.prompt, + runOptions: { maxRounds: start.maxRounds, depth: start.depth }, + historicalSteps: selected.value, + }); +} diff --git a/packages/workflow-execute/src/engine/gc.ts b/packages/workflow-execute/src/engine/gc.ts new file mode 100644 index 0000000..3d1007d --- /dev/null +++ b/packages/workflow-execute/src/engine/gc.ts @@ -0,0 +1,123 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { type CasStore, createCasStore } from "@uncaged/workflow-cas"; +import { err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow-util"; +import { parseThreadDataJsonl } from "./fork-thread.js"; +import type { GcResult } from "./types.js"; + +async function listThreadDataJsonlPaths(storageRoot: string): Promise> { + const logsRoot = join(storageRoot, "logs"); + const paths: string[] = []; + let hashes: string[]; + try { + hashes = await readdir(logsRoot); + } catch (e) { + const errObj = e as NodeJS.ErrnoException; + if (errObj.code === "ENOENT") { + return ok([]); + } + return err(`failed to read logs directory: ${String(e)}`); + } + + for (const hash of hashes) { + const dir = join(logsRoot, hash); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + continue; + } + for (const fileName of entries) { + if (fileName.endsWith(".data.jsonl")) { + paths.push(join(dir, fileName)); + } + } + } + + paths.sort(); + return ok(paths); +} + +async function collectActiveRefsFromDataPaths( + dataPaths: string[], +): Promise, string>> { + const activeRefs = new Set(); + for (const dataPath of dataPaths) { + let text: string; + try { + text = await readFile(dataPath, "utf8"); + } catch (e) { + return err(`failed to read ${dataPath}: ${String(e)}`); + } + const parsed = parseThreadDataJsonl(text); + if (!parsed.ok) { + return err(`${dataPath}: ${parsed.error}`); + } + for (const step of parsed.value.roleSteps) { + for (const ref of step.refs) { + activeRefs.add(ref); + } + } + } + return ok(activeRefs); +} + +async function deleteCasNotInSet( + cas: CasStore, + activeRefs: Set, +): Promise> { + let listed: string[]; + try { + listed = await cas.list(); + } catch (e) { + return err(`failed to list cas entries: ${String(e)}`); + } + + const deletedHashes: string[] = []; + for (const hash of listed) { + if (activeRefs.has(hash)) { + continue; + } + try { + await cas.delete(hash); + } catch (e) { + return err(`failed to delete cas ${hash}: ${String(e)}`); + } + deletedHashes.push(hash); + } + + deletedHashes.sort(); + return ok(deletedHashes); +} + +/** + * Mark-and-sweep CAS GC: collect `refs` from all thread `.data.jsonl` files under `storageRoot`, + * then delete CAS blobs not referenced by any surviving thread data. + */ +export async function garbageCollectCas(storageRoot: string): Promise> { + const pathsResult = await listThreadDataJsonlPaths(storageRoot); + if (!pathsResult.ok) { + return pathsResult; + } + const paths = pathsResult.value; + + const refsResult = await collectActiveRefsFromDataPaths(paths); + if (!refsResult.ok) { + return refsResult; + } + const activeRefs = refsResult.value; + + const cas = createCasStore(getGlobalCasDir(storageRoot)); + const deletedResult = await deleteCasNotInSet(cas, activeRefs); + if (!deletedResult.ok) { + return deletedResult; + } + const deletedHashes = deletedResult.value; + + return ok({ + scannedThreads: paths.length, + activeRefs: activeRefs.size, + deletedEntries: deletedHashes.length, + deletedHashes, + }); +} diff --git a/packages/workflow-execute/src/engine/index.ts b/packages/workflow-execute/src/engine/index.ts new file mode 100644 index 0000000..f7b6d74 --- /dev/null +++ b/packages/workflow-execute/src/engine/index.ts @@ -0,0 +1,23 @@ +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, + SupervisorDecision, + ThreadPauseGate, +} from "./types.js"; +export { getWorkerHostScriptPath } from "./worker-entry-path.js"; diff --git a/packages/workflow-execute/src/engine/supervisor.ts b/packages/workflow-execute/src/engine/supervisor.ts new file mode 100644 index 0000000..b5debe0 --- /dev/null +++ b/packages/workflow-execute/src/engine/supervisor.ts @@ -0,0 +1,85 @@ +import * as z from "zod/v4"; + +import { resolveModel } from "@uncaged/workflow-register"; +import { extractFunctionToolFromZodSchema } from "../extract/index.js"; +import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor"; +import type { WorkflowConfig } from "@uncaged/workflow-register"; +import { err, type LogFn, ok, type Result } from "@uncaged/workflow-util"; + +import type { SupervisorDecision } from "./types.js"; + +const SUPERVISOR_RECENT_STEP_LIMIT = 12; +const SUPERVISOR_MAX_REACT_ROUNDS = 4; + +const supervisorDecisionSchema = z + .object({ + decision: z.enum(["continue", "stop"]), + }) + .meta({ + title: "supervisor_decision", + description: + 'Workflow supervisor decision. "continue" when the thread is making progress; "stop" when done, looping, or stuck.', + }); + +type SupervisorThreadContext = Record; + +type RunSupervisorArgs = { + config: WorkflowConfig; + prompt: string; + recentSteps: readonly { role: string; summary: string }[]; + logger: LogFn; +}; + +function buildSupervisorInput(args: RunSupervisorArgs): string { + const recent = args.recentSteps.slice(-SUPERVISOR_RECENT_STEP_LIMIT); + const stepsBlock = recent.map((s, index) => `${index + 1}. [${s.role}] ${s.summary}`).join("\n"); + return `Original task:\n${args.prompt}\n\nRecent steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}`; +} + +/** Calls the `supervisor` scene via {@link createThreadReactor}; opt-out when {@link resolveModel} fails (returns ok(`continue`)). */ +export async function runSupervisor( + args: RunSupervisorArgs, +): Promise> { + const resolved = resolveModel(args.config, "supervisor"); + if (!resolved.ok) { + return ok("continue"); + } + + const reactor = createThreadReactor({ + llm: createLlmFn(resolved.value), + maxRounds: SUPERVISOR_MAX_REACT_ROUNDS, + staticTools: [], + structuredToolFromSchema: (schema) => { + const t = extractFunctionToolFromZodSchema(schema); + return { + name: t.name, + tool: { + type: "function" as const, + function: { + name: t.name, + description: t.description, + parameters: t.parameters, + }, + }, + }; + }, + systemPromptForStructuredTool: (structuredToolName) => + `You supervise a multi-step workflow. Decide whether the thread should keep running or halt. Reply with "continue" when the thread is making progress toward the task, or "stop" when it is finished, looping, or no longer making progress. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"stop"}.`, + toolHandler: async (call) => `Unknown tool: ${call.function.name}`, + }); + + const result = await reactor({ + thread: {} as SupervisorThreadContext, + input: buildSupervisorInput(args), + schema: supervisorDecisionSchema, + }); + + if (!result.ok) { + args.logger("R9CW4PLM", `supervisor failed: ${result.error}`); + return err(`supervisor: ${result.error}`); + } + + const decision: SupervisorDecision = result.value.decision; + args.logger("Z8KM5QWT", `supervisor says ${decision}`); + return ok(decision); +} diff --git a/packages/workflow-execute/src/engine/thread-pause-gate.ts b/packages/workflow-execute/src/engine/thread-pause-gate.ts new file mode 100644 index 0000000..3927a27 --- /dev/null +++ b/packages/workflow-execute/src/engine/thread-pause-gate.ts @@ -0,0 +1,49 @@ +import { err, ok, type Result } from "@uncaged/workflow-util"; + +import type { ThreadPauseGate } from "./types.js"; + +/** + * Pause/resume gate for workflow threads: after each generator yield the engine awaits + * {@link ThreadPauseGate.awaitAfterYield}. Calling {@link ThreadPauseGate.pause} makes the next + * await block until {@link ThreadPauseGate.resume}. + */ +export function createThreadPauseGate(): ThreadPauseGate { + let resumeResolver: (() => void) | null = null; + let chain: Promise = Promise.resolve(); + let paused = false; + + function awaitAfterYield(): Promise { + return chain; + } + + function pause(): Result { + if (paused) { + return err("thread already paused"); + } + paused = true; + chain = new Promise((resolve) => { + resumeResolver = resolve; + }); + return ok(undefined); + } + + function resume(): Result { + if (!paused) { + return err("thread not paused"); + } + paused = false; + const resolveFn = resumeResolver; + resumeResolver = null; + if (resolveFn !== null) { + resolveFn(); + } + chain = Promise.resolve(); + return ok(undefined); + } + + function isPaused(): boolean { + return paused; + } + + return { awaitAfterYield, pause, resume, isPaused }; +} diff --git a/packages/workflow-execute/src/engine/types.ts b/packages/workflow-execute/src/engine/types.ts new file mode 100644 index 0000000..32f7b54 --- /dev/null +++ b/packages/workflow-execute/src/engine/types.ts @@ -0,0 +1,75 @@ +import type { RoleOutput } from "@uncaged/workflow-runtime"; +import type { CasStore } from "@uncaged/workflow-cas"; +import type { Result } from "@uncaged/workflow-util"; + +export type SupervisorDecision = "continue" | "stop"; + +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; + refs: string[]; + timestamp: number; +}; + +export type ExecuteThreadOptions = { + maxRounds: number; + /** Passed to the bundle thread context as `ThreadContext.depth`. */ + depth: number; + signal: AbortSignal; + /** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */ + awaitAfterEachYield: () => Promise; + /** 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; + pause: () => Result; + resume: () => Result; + isPaused: () => boolean; +}; diff --git a/packages/workflow-execute/src/engine/worker-entry-path.ts b/packages/workflow-execute/src/engine/worker-entry-path.ts new file mode 100644 index 0000000..5690bd2 --- /dev/null +++ b/packages/workflow-execute/src/engine/worker-entry-path.ts @@ -0,0 +1,6 @@ +import { fileURLToPath } from "node:url"; + +/** Absolute path to `worker-host.ts` for spawning bundle worker processes. */ +export function getWorkerHostScriptPath(): string { + return fileURLToPath(new URL("./worker.ts", import.meta.url)); +} diff --git a/packages/workflow-execute/src/engine/worker.ts b/packages/workflow-execute/src/engine/worker.ts new file mode 100644 index 0000000..6c85901 --- /dev/null +++ b/packages/workflow-execute/src/engine/worker.ts @@ -0,0 +1,488 @@ +import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises"; +import { createServer, type Socket } from "node:net"; +import { dirname, join } from "node:path"; +import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime"; +import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "@uncaged/workflow-register"; +import { createCasStore } from "@uncaged/workflow-cas"; +import { + createLogger, + err, + getGlobalCasDir, + normalizeRefsField, + ok, + type Result, +} from "@uncaged/workflow-util"; +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" } }); + +type RunCommand = { + type: "run"; + threadId: string; + workflowName: string; + prompt: string; + options: { maxRounds: number; depth: number }; + steps: RoleOutput[]; + /** Timestamps aligned with `steps` for `.data.jsonl` replay; length must match `steps` when non-null. */ + stepTimestamps: number[] | null; + forkSourceThreadId: string | null; +}; + +type KillCommand = { + type: "kill"; + threadId: string; +}; + +type PauseCommand = { + type: "pause"; + threadId: string; +}; + +type ResumeCommand = { + type: "resume"; + threadId: string; +}; + +type ControlCommand = RunCommand | KillCommand | PauseCommand | ResumeCommand; + +type ThreadHandle = { + abortController: AbortController; + pauseGate: ThreadPauseGate; +}; + +function parseRoleOutputRecord(obj: Record): RoleOutput | null { + const role = obj.role; + const contentHash = obj.contentHash; + const meta = obj.meta; + if (typeof role !== "string" || typeof contentHash !== "string") { + return null; + } + if (meta === null || typeof meta !== "object") { + return null; + } + return { + role, + contentHash, + meta: meta as Record, + refs: normalizeRefsField(obj.refs), + }; +} + +function parseRunStepsPayload(rec: Record): { + steps: RoleOutput[]; + stepTimestamps: number[] | null; +} | null { + const raw = rec.steps; + if (raw === undefined || raw === null) { + return { steps: [], stepTimestamps: null }; + } + if (!Array.isArray(raw)) { + return null; + } + const steps: RoleOutput[] = []; + const timestamps: number[] = []; + let anyTimestamp = false; + for (const item of raw) { + if (item === null || typeof item !== "object") { + return null; + } + const o = item as Record; + const out = parseRoleOutputRecord(o); + if (out === null) { + return null; + } + steps.push(out); + const ts = o.timestamp; + if (ts === undefined) { + timestamps.push(0); + } else if (typeof ts === "number") { + timestamps.push(ts); + anyTimestamp = true; + } else { + return null; + } + } + return { + steps, + stepTimestamps: anyTimestamp ? timestamps : null, + }; +} + +function parseRunControlPayload(rec: Record): RunCommand | null { + const threadId = rec.threadId; + const workflowName = rec.workflowName; + const prompt = rec.prompt; + const options = rec.options; + if ( + typeof threadId !== "string" || + typeof workflowName !== "string" || + typeof prompt !== "string" + ) { + return null; + } + if (options === null || typeof options !== "object") { + return null; + } + const optRec = options as Record; + const maxRounds = optRec.maxRounds; + if (typeof maxRounds !== "number") { + return null; + } + const depthRaw = optRec.depth; + const depth = + typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0; + const parsedSteps = parseRunStepsPayload(rec); + if (parsedSteps === null) { + return null; + } + const rawFork = rec.forkSourceThreadId; + let forkSourceThreadId: string | null = null; + if (rawFork !== undefined && rawFork !== null) { + if (typeof rawFork !== "string" || rawFork === "") { + return null; + } + forkSourceThreadId = rawFork; + } + return { + type: "run", + threadId, + workflowName, + prompt, + options: { maxRounds, depth }, + steps: parsedSteps.steps, + stepTimestamps: parsedSteps.stepTimestamps, + forkSourceThreadId, + }; +} + +function parseLifecycleThreadPayload( + rec: Record, +): KillCommand | PauseCommand | ResumeCommand | null { + const type = rec.type; + const threadId = rec.threadId; + if (typeof threadId !== "string") { + return null; + } + if (type === "kill") { + return { type: "kill", threadId }; + } + if (type === "pause") { + return { type: "pause", threadId }; + } + if (type === "resume") { + return { type: "resume", threadId }; + } + return null; +} + +function parseControlPayload(payload: unknown): ControlCommand | null { + if (payload === null || typeof payload !== "object") { + return null; + } + const rec = payload as Record; + const lifecycle = parseLifecycleThreadPayload(rec); + if (lifecycle !== null) { + return lifecycle; + } + if (rec.type === "run") { + return parseRunControlPayload(rec); + } + return null; +} + +function parseCommandLine(line: string): ControlCommand | null { + const trimmed = line.trim(); + if (trimmed === "") { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed) as unknown; + } catch { + bootLog("S8KQ3WJP", "worker received invalid JSON control line"); + return null; + } + return parseControlPayload(parsed); +} + +function isWorkflowFnLike(value: unknown): value is WorkflowFn { + return typeof value === "function"; +} + +function writeTcpResponse(socket: Socket | null, result: Result): void { + if (socket === null) { + return; + } + const body = result.ok ? { ok: true as const } : { ok: false as const, error: result.error }; + socket.end(`${JSON.stringify(body)}\n`); +} + +function dispatchThreadLifecycleCommand( + threads: Map, + socket: Socket | null, + cmd: KillCommand | PauseCommand | ResumeCommand, +): void { + const handle = threads.get(cmd.threadId); + if (handle === undefined) { + writeTcpResponse(socket, err(`thread not found: ${cmd.threadId}`)); + return; + } + switch (cmd.type) { + case "kill": + handle.abortController.abort(); + bootLog("P9XK2WNQ", `kill requested for thread ${cmd.threadId}`); + writeTcpResponse(socket, ok(undefined)); + return; + case "pause": { + const paused = handle.pauseGate.pause(); + if (!paused.ok) { + writeTcpResponse(socket, paused); + return; + } + bootLog("K7WQ2NXP", `pause requested for thread ${cmd.threadId}`); + writeTcpResponse(socket, ok(undefined)); + return; + } + case "resume": { + const resumed = handle.pauseGate.resume(); + if (!resumed.ok) { + writeTcpResponse(socket, resumed); + return; + } + bootLog("M4YT8HKR", `resume requested for thread ${cmd.threadId}`); + writeTcpResponse(socket, ok(undefined)); + return; + } + } +} + +async function readLineFromSocket(socket: Socket): Promise { + return await new Promise((resolve) => { + let buf = ""; + function onData(chunk: Buffer): void { + buf += chunk.toString("utf8"); + const nl = buf.indexOf("\n"); + if (nl >= 0) { + cleanup(); + resolve(buf.slice(0, nl)); + } + } + function onEnd(): void { + cleanup(); + resolve(buf === "" ? null : buf); + } + function onError(): void { + cleanup(); + resolve(null); + } + function cleanup(): void { + socket.off("data", onData); + socket.off("end", onEnd); + socket.off("error", onError); + } + socket.on("data", onData); + socket.on("end", onEnd); + socket.on("error", onError); + }); +} + +async function main(): Promise { + const bundlePath = process.argv[2]; + const storageRoot = process.argv[3]; + const hash = process.argv[4]; + + if ( + bundlePath === undefined || + storageRoot === undefined || + hash === undefined || + bundlePath === "" || + storageRoot === "" || + hash === "" + ) { + bootLog("H7XN4MKQ", "worker usage: worker "); + process.exit(2); + return; + } + + await ensureUncagedWorkflowSymlink(storageRoot); + // Dynamic import required: user bundle path resolved at runtime + const modUnknown: unknown = await importWorkflowBundleModule(bundlePath); + const modRec = modUnknown as Record; + const runExport = modRec.run; + if (!isWorkflowFnLike(runExport)) { + bootLog("T4BW9YJX", "workflow bundle must export run as a function (AsyncGenerator workflow)"); + process.exit(2); + return; + } + const workflowFn = runExport; + + const threads = new Map(); + let activeThreads = 0; + let shutdownTimer: ReturnType | null = null; + + const cas = createCasStore(getGlobalCasDir(storageRoot)); + + const workerCtlPath = join(storageRoot, "workers", `${hash}.json`); + + function cancelShutdownTimer(): void { + if (shutdownTimer !== null) { + clearTimeout(shutdownTimer); + shutdownTimer = null; + } + } + + function scheduleShutdown(): void { + cancelShutdownTimer(); + shutdownTimer = setTimeout(() => { + void unlink(workerCtlPath).catch(() => {}); + process.exit(0); + }, 150); + } + + function bumpStart(): void { + cancelShutdownTimer(); + activeThreads++; + } + + function bumpDone(): void { + activeThreads--; + if (activeThreads <= 0) { + activeThreads = 0; + scheduleShutdown(); + } + } + + async function dispatchCommand(cmd: ControlCommand, socket: Socket | null): Promise { + if (cmd.type !== "run") { + dispatchThreadLifecycleCommand(threads, socket, cmd); + return; + } + + bumpStart(); + + const threadId = cmd.threadId; + const runningPath = join(storageRoot, "logs", hash, `${threadId}.running`); + const dataJsonlPath = join(storageRoot, "logs", hash, `${threadId}.data.jsonl`); + const infoJsonlPath = join(storageRoot, "logs", hash, `${threadId}.info.jsonl`); + + const io: ExecuteThreadIo = { + threadId, + hash, + dataJsonlPath, + infoJsonlPath, + cas, + }; + + const existing = threads.get(threadId); + if (existing !== undefined) { + existing.abortController.abort(); + threads.delete(threadId); + } + + const pauseGate = createThreadPauseGate(); + const ac = new AbortController(); + threads.set(threadId, { abortController: ac, pauseGate }); + + try { + await mkdir(dirname(runningPath), { recursive: true }); + await mkdir(dirname(dataJsonlPath), { recursive: true }); + await writeFile(runningPath, "", "utf8"); + + const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } }); + + const baseTs = Date.now(); + let prefilledDiskSteps: PrefilledDiskStep[] | null = null; + if (cmd.steps.length > 0) { + prefilledDiskSteps = cmd.steps.map((step, i) => { + const ts = cmd.stepTimestamps?.[i]; + return { + role: step.role, + contentHash: step.contentHash, + meta: step.meta, + refs: normalizeRefsField(step.refs), + timestamp: typeof ts === "number" && ts > 0 ? ts : baseTs + i, + }; + }); + } + + const runResult = await executeThread( + workflowFn, + cmd.workflowName, + { prompt: cmd.prompt, steps: cmd.steps }, + { + ...cmd.options, + signal: ac.signal, + awaitAfterEachYield: () => pauseGate.awaitAfterYield(), + forkSourceThreadId: cmd.forkSourceThreadId, + prefilledDiskSteps, + storageRoot, + }, + io, + logger, + ); + await appendFile(dataJsonlPath, `${JSON.stringify(runResult)}\n`, "utf8"); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + bootLog("Q3MN8YKW", `thread ${threadId} failed: ${message}`); + const failure: WorkflowResult = { returnCode: 1, summary: message, rootHash: "" }; + await appendFile(dataJsonlPath, `${JSON.stringify(failure)}\n`, "utf8").catch(() => {}); + } finally { + threads.delete(threadId); + await unlink(runningPath).catch(() => {}); + bumpDone(); + socket?.end(); + } + } + + if (typeof process.send === "function") { + process.on("message", (msg: unknown) => { + const cmd = parseControlPayload(msg); + if (cmd === null) { + return; + } + void dispatchCommand(cmd, null); + }); + } + + const server = createServer((socket: Socket) => { + void (async () => { + const line = await readLineFromSocket(socket); + if (line === null) { + socket.end(); + return; + } + const cmd = parseCommandLine(line); + if (cmd === null) { + socket.end(); + return; + } + await dispatchCommand(cmd, socket); + })(); + }); + + server.on("error", (errObj: Error) => { + bootLog("W8YK4NPX", `worker server error: ${errObj.message}`); + process.exit(1); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve(); + }); + }); + + const addr = server.address(); + if (addr === null || typeof addr === "string") { + bootLog("R9XK4MNW", "worker failed to bind TCP address"); + process.exit(1); + return; + } + + process.stdout.write(`READY ${addr.port}\n`); + + await new Promise(() => {}); +} + +void main(); diff --git a/packages/workflow-execute/src/extract/extract-fn.ts b/packages/workflow-execute/src/extract/extract-fn.ts new file mode 100644 index 0000000..83e2d26 --- /dev/null +++ b/packages/workflow-execute/src/extract/extract-fn.ts @@ -0,0 +1,136 @@ +import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime"; +import type * as z from "zod/v4"; +import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas"; +import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor"; +import { extractFunctionToolFromZodSchema } from "./llm-extract.js"; + +export type ExtractDeps = { + cas: CasStore; +}; + +const MAX_REACT_ROUNDS = 10; + +const CAS_GET_TOOL_DEFINITION = { + type: "function" as const, + function: { + name: "cas_get", + description: + "Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and children fields.", + parameters: { + type: "object", + properties: { + hash: { type: "string", description: "The CAS hash to retrieve" }, + }, + required: ["hash"], + }, + }, +}; + +export type ExtractThreadContext = { + cas: CasStore; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Builds the user-side extraction prompt (thread + agent output + instruction). */ +export async function buildExtractUserContent( + ctx: ExtractContext, + prompt: string, + deps: ExtractDeps, +): Promise { + const lines: string[] = []; + lines.push(`## Role: ${ctx.currentRole.name}`); + lines.push(ctx.currentRole.systemPrompt); + lines.push(""); + lines.push("## Task"); + lines.push(ctx.start.content); + lines.push(""); + if (ctx.steps.length > 0) { + lines.push("## Thread History"); + for (const step of ctx.steps) { + const body = await getContentMerklePayload(deps.cas, step.contentHash); + if (body === null) { + throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`); + } + lines.push(`### ${step.role}`); + lines.push(body); + lines.push(`Meta: ${JSON.stringify(step.meta)}`); + lines.push(""); + } + } + lines.push("## Agent Output"); + lines.push(ctx.agentContent); + lines.push(""); + lines.push("## Extraction Instruction"); + lines.push(prompt); + + return lines.join("\n"); +} + +/** + * Create an ExtractFn backed by an LLM provider. + * + * Internally runs a multi-turn ReAct loop with two tools (`cas_get` for traversing the + * Merkle DAG and a schema-shaped extract tool); the loop also accepts a plain-JSON + * assistant reply as a short-circuit, which covers the legacy "single" extraction path. + */ +export function createExtract(provider: LlmProvider, deps: ExtractDeps): ExtractFn { + const llm = createLlmFn(provider); + const reactor = createThreadReactor({ + llm, + maxRounds: MAX_REACT_ROUNDS, + staticTools: [CAS_GET_TOOL_DEFINITION], + structuredToolFromSchema: (schema) => { + const t = extractFunctionToolFromZodSchema(schema); + return { + name: t.name, + tool: { + type: "function" as const, + function: { + name: t.name, + description: t.description, + parameters: t.parameters, + }, + }, + }; + }, + systemPromptForStructuredTool: (structuredToolName) => + `You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, children) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${structuredToolName} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`, + toolHandler: async (call, thread) => { + if (call.function.name !== "cas_get") { + return `Unexpected tool routed to handler: ${call.function.name}`; + } + let hash: string; + try { + const ta = JSON.parse(call.function.arguments) as unknown; + if (!isRecord(ta) || typeof ta.hash !== "string") { + return 'cas_get requires a JSON object with a string "hash" field.'; + } + hash = ta.hash; + } catch { + return 'cas_get arguments were not valid JSON. Provide {"hash": ""}.'; + } + const blob = await thread.cas.get(hash); + return blob === null ? "null" : blob; + }, + }); + + return async >( + schema: z.ZodType, + prompt: string, + ctx: ExtractContext, + ): Promise => { + const text = await buildExtractUserContent(ctx, prompt, deps); + const result = await reactor({ + thread: { cas: deps.cas }, + input: text, + schema, + }); + if (!result.ok) { + throw new Error(`extract failed: ${result.error}`); + } + return result.value; + }; +} diff --git a/packages/workflow-execute/src/extract/index.ts b/packages/workflow-execute/src/extract/index.ts new file mode 100644 index 0000000..e1069af --- /dev/null +++ b/packages/workflow-execute/src/extract/index.ts @@ -0,0 +1,11 @@ +export { + buildExtractUserContent, + createExtract, + type ExtractThreadContext, +} from "./extract-fn.js"; +export { + extractFunctionToolFromZodSchema, + llmErrorToCause, + llmExtract, +} from "./llm-extract.js"; +export type { ExtractFn, LlmError, LlmExtractArgs } from "./types.js"; diff --git a/packages/workflow-execute/src/extract/llm-extract.ts b/packages/workflow-execute/src/extract/llm-extract.ts new file mode 100644 index 0000000..09e0926 --- /dev/null +++ b/packages/workflow-execute/src/extract/llm-extract.ts @@ -0,0 +1,194 @@ +import * as z from "zod/v4"; + +import { err, ok, type Result } from "@uncaged/workflow-util"; + +import type { LlmError, LlmExtractArgs } from "./types.js"; + +function chatCompletionsUrl(baseUrl: string): string { + const trimmed = baseUrl.replace(/\/+$/, ""); + return `${trimmed}/chat/completions`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stripJsonSchemaMeta(json: Record): Record { + const { $schema: _drop, ...rest } = json; + return rest; +} + +function readToolName(parametersSchema: Record): string { + const title = parametersSchema.title; + if (typeof title === "string" && title.trim().length > 0) { + return title.trim(); + } + return "extract"; +} + +function readToolDescription(parametersSchema: Record): string { + const d = parametersSchema.description; + if (typeof d === "string" && d.trim().length > 0) { + return d.trim(); + } + return "Extract structured data from the input text."; +} + +/** Builds OpenAI function-tool metadata from a Zod meta schema (same naming rules as single-shot extract). */ +export function extractFunctionToolFromZodSchema(schema: z.ZodType): { + name: string; + description: string; + parameters: Record; +} { + const rawJsonSchema = z.toJSONSchema(schema) as Record; + const parameters = stripJsonSchemaMeta(rawJsonSchema); + return { + name: readToolName(parameters), + description: readToolDescription(parameters), + parameters, + }; +} + +function readToolArgumentsJson(parsed: unknown, previewSource: string): Result { + if (!isRecord(parsed)) { + return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" }); + } + + const choices = parsed.choices; + if (!Array.isArray(choices) || choices.length === 0) { + return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) }); + } + + const first = choices[0]; + if (!isRecord(first)) { + return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) }); + } + + const messageObj = first.message; + if (!isRecord(messageObj)) { + return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) }); + } + + const toolCalls = messageObj.tool_calls; + if (!Array.isArray(toolCalls) || toolCalls.length === 0) { + return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) }); + } + + const call0 = toolCalls[0]; + if (!isRecord(call0)) { + return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) }); + } + + const fn = call0.function; + if (!isRecord(fn)) { + return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) }); + } + + const argsRaw = fn.arguments; + if (typeof argsRaw !== "string") { + return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) }); + } + + return ok(argsRaw); +} + +export function llmErrorToCause(error: LlmError): Error { + switch (error.kind) { + case "http_error": + return new Error(`HTTP ${error.status}: ${error.body.slice(0, 500)}`); + case "invalid_response_json": + return new Error(error.message); + case "no_tool_call": + return new Error(`No tool call in response: ${error.preview}`); + case "tool_arguments_invalid_json": + return new Error(error.message); + case "schema_validation_failed": + return new Error(error.message); + case "network_error": + return new Error(error.message); + } +} + +async function performLlmExtract( + options: LlmExtractArgs & { userContent: string }, +): Promise> { + const extractTool = extractFunctionToolFromZodSchema(options.schema); + + const body = { + model: options.provider.model, + messages: [ + { + role: "system" as const, + content: "Extract the requested information from the provided text. Be precise.", + }, + { role: "user" as const, content: options.userContent }, + ], + tools: [ + { + type: "function" as const, + function: { + name: extractTool.name, + description: extractTool.description, + parameters: extractTool.parameters, + }, + }, + ], + tool_choice: { type: "function" as const, function: { name: extractTool.name } }, + }; + + let response: Response; + try { + response = await fetch(chatCompletionsUrl(options.provider.baseUrl), { + method: "POST", + headers: { + Authorization: `Bearer ${options.provider.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return err({ kind: "network_error", message }); + } + + const responseText = await response.text(); + if (!response.ok) { + return err({ kind: "http_error", status: response.status, body: responseText.slice(0, 4000) }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(responseText) as unknown; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return err({ kind: "invalid_response_json", message }); + } + + const argsJson = readToolArgumentsJson(parsed, responseText); + if (!argsJson.ok) { + return argsJson; + } + + let argsParsed: unknown; + try { + argsParsed = JSON.parse(argsJson.value) as unknown; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return err({ kind: "tool_arguments_invalid_json", message }); + } + + const validated = options.schema.safeParse(argsParsed); + if (!validated.success) { + return err({ + kind: "schema_validation_failed", + message: validated.error.message, + }); + } + + return ok(validated.data); +} + +/** Single LLM extract attempt over OpenAI-compatible chat completions with forced tool call. */ +export async function llmExtract(options: LlmExtractArgs): Promise> { + return performLlmExtract({ ...options, userContent: options.text }); +} diff --git a/packages/workflow-execute/src/extract/types.ts b/packages/workflow-execute/src/extract/types.ts new file mode 100644 index 0000000..c5bf283 --- /dev/null +++ b/packages/workflow-execute/src/extract/types.ts @@ -0,0 +1,18 @@ +import type { LlmProvider } from "@uncaged/workflow-runtime"; +import type * as z from "zod/v4"; + +export type { ExtractFn } from "@uncaged/workflow-runtime"; + +export type LlmExtractArgs = { + text: string; + schema: z.ZodType; + 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 }; diff --git a/packages/workflow-execute/src/index.ts b/packages/workflow-execute/src/index.ts new file mode 100644 index 0000000..2571d7e --- /dev/null +++ b/packages/workflow-execute/src/index.ts @@ -0,0 +1,35 @@ +export { createWorkflow } from "./engine/create-workflow.js"; +export { executeThread } from "./engine/engine.js"; +export { + buildForkPlan, + parseThreadDataJsonl, + selectForkHistoricalSteps, + tryParseRoleStepRecord, + tryParseWorkflowResultRecord, +} from "./engine/fork-thread.js"; +export { garbageCollectCas } from "./engine/gc.js"; +export { createThreadPauseGate } from "./engine/thread-pause-gate.js"; +export type { + ExecuteThreadIo, + ExecuteThreadOptions, + ForkHistoricalStep, + ForkPlan, + GcResult, + ParsedThreadStartRecord, + PrefilledDiskStep, + SupervisorDecision, + ThreadPauseGate, +} from "./engine/types.js"; +export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js"; +export { + buildExtractUserContent, + createExtract, + type ExtractThreadContext, +} from "./extract/index.js"; +export { + extractFunctionToolFromZodSchema, + llmErrorToCause, + llmExtract, +} from "./extract/index.js"; +export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js"; +export { workflowAsAgent, type WorkflowAsAgentOptions } from "./workflow-as-agent.js"; diff --git a/packages/workflow-execute/src/workflow-as-agent.ts b/packages/workflow-execute/src/workflow-as-agent.ts new file mode 100644 index 0000000..2813361 --- /dev/null +++ b/packages/workflow-execute/src/workflow-as-agent.ts @@ -0,0 +1,114 @@ +import { join } from "node:path"; +import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime"; +import { extractBundleExports } from "@uncaged/workflow-register"; +import { createCasStore } from "@uncaged/workflow-cas"; +import type { ExecuteThreadIo } from "./engine/index.js"; +import { executeThread } from "./engine/index.js"; +import type { WorkflowConfig } from "@uncaged/workflow-register"; +import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register"; +import { + createLogger, + generateUlid, + getDefaultWorkflowStorageRoot, + getGlobalCasDir, +} from "@uncaged/workflow-util"; + +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()`. */ + storageRoot: string | null; +}; + +function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | null): string { + if (options !== null && options.storageRoot !== null) { + return options.storageRoot; + } + return getDefaultWorkflowStorageRoot(); +} + +/** + * Returns an {@link AgentFn} that runs another registered workflow in a new thread, + * using the parent thread's initial prompt (`ctx.start.content`) as the child prompt. + */ +export function workflowAsAgent( + workflowName: string, + options: WorkflowAsAgentOptions | null = null, +): AgentFn { + return async (ctx: AgentContext): Promise => { + const nextDepth = ctx.depth + 1; + + const storageRoot = resolveWorkflowAsAgentStorageRoot(options); + + const registryResult = await readWorkflowRegistry(storageRoot); + if (!registryResult.ok) { + return `ERROR: failed to read workflow registry: ${registryResult.error.message}`; + } + + const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config); + if (nextDepth > maxDepth) { + return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`; + } + + const entry = getRegisteredWorkflow(registryResult.value, workflowName); + if (entry === null) { + return `ERROR: workflow "${workflowName}" not found in registry`; + } + + const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`); + const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot }); + if (!bundleExportsResult.ok) { + return `ERROR: ${bundleExportsResult.error}`; + } + + const input = { + prompt: ctx.start.content, + steps: [], + }; + + const childThreadId = generateUlid(Date.now()); + const dataJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.data.jsonl`); + const infoJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.info.jsonl`); + + const io: ExecuteThreadIo = { + threadId: childThreadId, + hash: entry.hash, + dataJsonlPath, + infoJsonlPath, + cas: createCasStore(getGlobalCasDir(storageRoot)), + }; + + const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } }); + const signalNever = new AbortController(); + + try { + const result = await executeThread( + bundleExportsResult.value.run, + workflowName, + input, + { + maxRounds: ctx.start.meta.maxRounds, + depth: nextDepth, + signal: signalNever.signal, + awaitAfterEachYield: async () => {}, + forkSourceThreadId: ctx.threadId, + prefilledDiskSteps: null, + storageRoot, + }, + io, + logger, + ); + return result.rootHash; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return `ERROR: ${message}`; + } + }; +} diff --git a/packages/workflow-execute/tsconfig.json b/packages/workflow-execute/tsconfig.json new file mode 100644 index 0000000..75eba9f --- /dev/null +++ b/packages/workflow-execute/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/workflow-register/src/bundle/bundle-validator.ts b/packages/workflow-register/src/bundle/bundle-validator.ts index 1ebfbd8..8538cf0 100644 --- a/packages/workflow-register/src/bundle/bundle-validator.ts +++ b/packages/workflow-register/src/bundle/bundle-validator.ts @@ -38,7 +38,11 @@ function isAllowedImportSpecifier(spec: string): boolean { if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) { return false; } - if (spec === "@uncaged/workflow" || spec === "@uncaged/workflow-runtime") { + if ( + spec === "@uncaged/workflow" || + spec === "@uncaged/workflow-runtime" || + spec === "@uncaged/workflow-cas" + ) { return true; } return isBuiltin(spec); @@ -294,7 +298,7 @@ function validateImportDeclaration(node: ImportDeclaration): string | null { return "only static string import specifiers are allowed"; } if (!isAllowedImportSpecifier(spec)) { - return `disallowed import specifier "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`; + return `disallowed import specifier "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`; } return null; } @@ -309,7 +313,7 @@ function validateExportSource( return staticMessage; } if (!isAllowedImportSpecifier(spec)) { - return `${disallowedPrefix} "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`; + return `${disallowedPrefix} "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`; } return null; } diff --git a/packages/workflow-register/src/bundle/ensure-uncaged-workflow-symlink.ts b/packages/workflow-register/src/bundle/ensure-uncaged-workflow-symlink.ts index 247444b..cecc319 100644 --- a/packages/workflow-register/src/bundle/ensure-uncaged-workflow-symlink.ts +++ b/packages/workflow-register/src/bundle/ensure-uncaged-workflow-symlink.ts @@ -2,21 +2,23 @@ import { mkdir, readlink, symlink, unlink } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -/** This module lives in `@uncaged/workflow/src/bundle`; grandparent dir is the package root. */ +/** This module lives in `@uncaged/workflow-register/src/bundle`; grandparent dir is the package root. */ function installedWorkflowPackageDir(): string { return fileURLToPath(new URL("../..", import.meta.url)); } /** - * Ensures `/node_modules/@uncaged/workflow` points at the installed `@uncaged/workflow` - * package so workflow bundles loaded from `/bundles/*.esm.js` can resolve `import "@uncaged/workflow"`. + * Resolve sibling @uncaged/* package directory relative to workflow-register. + * In a monorepo workspace layout the sibling packages live next to workflow-register. */ -export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise { - const target = installedWorkflowPackageDir(); - const linkDir = path.join(storageRoot, "node_modules", "@uncaged"); - const linkPath = path.join(linkDir, "workflow"); - await mkdir(linkDir, { recursive: true }); +function siblingPackageDir(packageName: string): string { + const registerRoot = installedWorkflowPackageDir(); + return path.resolve(registerRoot, "..", packageName); +} +async function ensureSymlink(linkDir: string, name: string, target: string): Promise { + const linkPath = path.join(linkDir, name); + await mkdir(linkDir, { recursive: true }); try { const existing = await readlink(linkPath); const normalizedExisting = path.resolve(linkDir, existing); @@ -30,7 +32,25 @@ export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise throw e; } } - const linkType = process.platform === "win32" ? "junction" : "dir"; await symlink(target, linkPath, linkType); } + +/** + * Ensures `/node_modules/@uncaged/*` symlinks point at installed packages + * so workflow bundles loaded from `/bundles/*.esm.js` can resolve their imports. + */ +export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise { + const linkDir = path.join(storageRoot, "node_modules", "@uncaged"); + + const packages = [ + { name: "workflow", dir: siblingPackageDir("workflow") }, + { name: "workflow-runtime", dir: siblingPackageDir("workflow-runtime") }, + { name: "workflow-cas", dir: siblingPackageDir("workflow-cas") }, + { name: "workflow-protocol", dir: siblingPackageDir("workflow-protocol") }, + ]; + + for (const pkg of packages) { + await ensureSymlink(linkDir, pkg.name, pkg.dir); + } +}