Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoju 4a43a7f3dd refactor: update all consumers to import from @uncaged/workflow
- workflow-utils, workflow-meta: import workflow types from @uncaged/workflow
- adapter-cursor, adapter-hermes: same
- cli: same
- core: remove workflow re-exports, no longer depends on @uncaged/workflow

Phase 5+6 of #320, Testing: #323
2026-05-05 11:01:08 +00:00
xiaoju cee65bbd87 refactor(workflow): move IPC, worker, manager from daemon to @uncaged/workflow
- Move workflow IPC types (StartThread, ResumeThread, etc.) to workflow/ipc.ts
- Move workflow-worker.ts, workflow-manager.ts, workflow-manager-support.ts
- Move worker-runtime.ts and worker-signals.ts (shared infrastructure)
- Daemon now imports workflow runtime from @uncaged/workflow
- Export WORKFLOW_WORKER_PATH for daemon to spawn workers

Phase 3+4 of #320, Testing: #322
2026-05-05 10:41:59 +00:00
xiaoju 591be21bb0 refactor(workflow): scaffold @uncaged/workflow, move types from core
- Create packages/workflow/ with types.ts (from core/workflow.ts) and config.ts
- Core re-exports workflow types from @uncaged/workflow
- Delete packages/core/src/workflow.ts

Phase 1+2 of #320, Testing: #321
2026-05-05 10:27:08 +00:00
xiaoju 7d0200fa15 docs: add implementation plan for @uncaged/workflow extraction
Refs #320
2026-05-05 10:16:33 +00:00
75 changed files with 1267 additions and 365 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Always use static top-level `import` statements.
## Exceptions (must include a comment explaining why)
1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime
2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
2. **`packages/workflow/src/worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
When suppressing, add a comment directly above:
@@ -0,0 +1,496 @@
# Extract Workflow Engine into `@uncaged/workflow` — Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Extract the workflow engine (types, runtime, IPC, manager) from nerve-core and nerve-daemon into a standalone `@uncaged/workflow` package.
**Architecture:** Create `packages/workflow/` as a new pnpm workspace package. Move workflow types from core and workflow runtime from daemon into it. The daemon becomes a consumer of `@uncaged/workflow`. No backward-compat re-exports — breaking change, update all consumers in one shot.
**Tech Stack:** TypeScript, pnpm workspace, rslib (bundler, same as other packages), Biome
**Ref:** Fixes #320
---
## Phase 1: Scaffold `packages/workflow/`
### Task 1: Create package skeleton
**Objective:** Create the `@uncaged/workflow` package with package.json, tsconfig, rslib config.
**Files:**
- Create: `packages/workflow/package.json`
- Create: `packages/workflow/tsconfig.json`
- Create: `packages/workflow/rslib.config.ts`
- Create: `packages/workflow/src/index.ts` (empty barrel)
**Steps:**
1. Copy `packages/workflow-utils/package.json` as template, change name to `@uncaged/workflow`, remove all dependencies except dev deps (typescript, rslib, etc.). No runtime deps initially.
2. Copy `packages/workflow-utils/tsconfig.json` and `rslib.config.ts` as-is (same monorepo conventions).
3. Create empty `packages/workflow/src/index.ts`:
```typescript
// @uncaged/workflow — standalone workflow orchestration engine
```
4. Verify:
```bash
cd packages/workflow && pnpm install && pnpm run build
```
5. Commit:
```bash
git add packages/workflow/
git commit -m "chore(workflow): scaffold @uncaged/workflow package
Refs #320"
```
---
## Phase 2: Move Types from Core
### Task 2: Move `workflow.ts` types to `@uncaged/workflow`
**Objective:** Move all workflow types and constants from `packages/core/src/workflow.ts``packages/workflow/src/types.ts`.
**Files:**
- Create: `packages/workflow/src/types.ts`
- Modify: `packages/workflow/src/index.ts`
**Steps:**
1. Copy `packages/core/src/workflow.ts``packages/workflow/src/types.ts` verbatim (all 83 lines — `START`, `END`, `DEFAULT_ENGINE_MAX_ROUNDS`, all types).
2. Export everything from `packages/workflow/src/index.ts`:
```typescript
export {
START,
END,
DEFAULT_ENGINE_MAX_ROUNDS,
} from "./types.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./types.js";
```
3. Build to verify types compile:
```bash
cd packages/workflow && pnpm run build
```
4. Commit:
```bash
git commit -am "refactor(workflow): move workflow types from core to @uncaged/workflow
Refs #320"
```
### Task 3: Move `WorkflowConfig` to `@uncaged/workflow`
**Objective:** Move the `WorkflowConfig` type (and its constituent types `DropOverflowConfig`, `QueueOverflowConfig`) from core/config.ts to the workflow package.
**Files:**
- Create: `packages/workflow/src/config.ts`
- Modify: `packages/workflow/src/index.ts`
- Modify: `packages/core/src/config.ts` — remove `WorkflowConfig`, `DropOverflowConfig`, `QueueOverflowConfig`; import from `@uncaged/workflow` instead
**Steps:**
1. Create `packages/workflow/src/config.ts` with the three types:
```typescript
export type DropOverflowConfig = {
concurrency: number;
overflow: "drop";
};
export type QueueOverflowConfig = {
concurrency: number;
overflow: "queue";
maxQueue: number;
};
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
```
2. Re-export from `packages/workflow/src/index.ts`.
3. In `packages/core/package.json`, add dependency: `"@uncaged/workflow": "workspace:*"`.
4. In `packages/core/src/config.ts`:
- Remove the three type definitions
- Add `import type { WorkflowConfig, DropOverflowConfig, QueueOverflowConfig } from "@uncaged/workflow";`
- Keep the re-export from `packages/core/src/index.ts` pointing to config.ts (which now re-exports from workflow)
5. Build entire workspace:
```bash
pnpm run build
```
6. Commit:
```bash
git commit -am "refactor(workflow): move WorkflowConfig types to @uncaged/workflow
Refs #320"
```
### Task 4: Remove workflow types from core, update core exports
**Objective:** Delete `packages/core/src/workflow.ts` entirely. Core re-exports workflow types from `@uncaged/workflow`.
**Files:**
- Delete: `packages/core/src/workflow.ts`
- Modify: `packages/core/src/index.ts` — change workflow exports to re-export from `@uncaged/workflow`
- Modify: `packages/core/src/config.ts` — remove import of `DEFAULT_ENGINE_MAX_ROUNDS` from deleted file
**Steps:**
1. In `packages/core/src/config.ts`, replace:
```typescript
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
```
with:
```typescript
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
```
2. In `packages/core/src/index.ts`, replace all workflow.js imports with re-exports from `@uncaged/workflow`:
```typescript
// Workflow types — re-exported from @uncaged/workflow
export {
START, END, DEFAULT_ENGINE_MAX_ROUNDS,
} from "@uncaged/workflow";
export type {
WorkflowMessage, RoleResult, Role, RoleMeta, StartStep,
ThreadContext, WorkflowContext, AgentFn, RoleStep,
ModeratorContext, Moderator, WorkflowDefinition,
} from "@uncaged/workflow";
```
3. Delete `packages/core/src/workflow.ts`.
4. Full build + test:
```bash
pnpm run build && pnpm test
```
5. Commit:
```bash
git commit -am "refactor(core): remove workflow.ts, re-export from @uncaged/workflow
Refs #320"
```
---
## Phase 3: Move Workflow IPC Messages
### Task 5: Extract workflow IPC types to `@uncaged/workflow`
**Objective:** Move workflow-related IPC message types from `packages/daemon/src/ipc.ts` to `packages/workflow/src/ipc.ts`. Sense IPC stays in daemon.
**Files:**
- Create: `packages/workflow/src/ipc.ts`
- Modify: `packages/workflow/src/index.ts`
- Modify: `packages/daemon/src/ipc.ts` — remove workflow IPC types, import from `@uncaged/workflow`
**Steps:**
1. Extract to `packages/workflow/src/ipc.ts`:
- `StartThreadMessage`, `ResumeThreadMessage`, `KillThreadMessage`
- `ThreadLifecycleEvent`, `ThreadEventMessage`, `WorkflowErrorMessage`, `ThreadWorkflowMessage`
- Workflow-related validation logic from `parseParentToWorkerMessage`
- Union type for workflow parent→worker messages
2. Keep in daemon `ipc.ts`:
- `ComputeMessage`, `ShutdownMessage`, `HealthRequestMessage`
- Sense-related worker→parent messages
- The combined `ParentToWorkerMessage` union (imports workflow types from `@uncaged/workflow`)
3. Add `@uncaged/workflow` as dependency to `packages/daemon/package.json`.
4. Build + test:
```bash
pnpm run build && pnpm test
```
5. Commit:
```bash
git commit -am "refactor(workflow): move workflow IPC types to @uncaged/workflow
Refs #320"
```
---
## Phase 4: Move Workflow Runtime
### Task 6: Move `workflow-worker.ts` to `@uncaged/workflow`
**Objective:** Move workflow execution runtime (the worker that runs inside a child process) from daemon to the workflow package.
**Files:**
- Move: `packages/daemon/src/workflow-worker.ts` → `packages/workflow/src/worker.ts`
- Modify: `packages/workflow/src/index.ts`
- Modify: `packages/daemon/` — update worker spawn path
**Steps:**
1. Copy `workflow-worker.ts` to `packages/workflow/src/worker.ts`.
2. Update imports: replace `@uncaged/nerve-core` with local imports from `./types.js`, `./ipc.js`.
3. Export the worker entry point or the worker file path from `@uncaged/workflow` so daemon can spawn it.
4. In daemon, update worker spawn to reference `@uncaged/workflow`'s worker.
5. Delete `packages/daemon/src/workflow-worker.ts`.
6. Build + test:
```bash
pnpm run build && pnpm test
```
7. Commit:
```bash
git commit -am "refactor(workflow): move workflow-worker runtime to @uncaged/workflow
Refs #320"
```
### Task 7: Move `workflow-manager.ts` and `workflow-manager-support.ts` to `@uncaged/workflow`
**Objective:** Move workflow process management from daemon to workflow package.
**Files:**
- Move: `packages/daemon/src/workflow-manager.ts` → `packages/workflow/src/manager.ts`
- Move: `packages/daemon/src/workflow-manager-support.ts` → `packages/workflow/src/manager-support.ts`
- Modify: `packages/daemon/src/kernel.ts` — import from `@uncaged/workflow`
**Steps:**
1. Copy both files to `packages/workflow/src/`.
2. Update imports to use local paths within the workflow package.
3. Export manager creation function from `packages/workflow/src/index.ts`.
4. Update `packages/daemon/src/kernel.ts` to import workflow manager from `@uncaged/workflow`.
5. Delete the original files from daemon.
6. Build + test:
```bash
pnpm run build && pnpm test
```
7. Commit:
```bash
git commit -am "refactor(workflow): move workflow-manager to @uncaged/workflow
Refs #320"
```
---
## Phase 5: Update All Consumers
### Task 8: Update `workflow-utils` to depend on `@uncaged/workflow`
**Objective:** Change `@uncaged/nerve-workflow-utils` to import workflow types from `@uncaged/workflow` instead of `@uncaged/nerve-core`.
**Files:**
- Modify: `packages/workflow-utils/package.json` — replace `@uncaged/nerve-core` dep with `@uncaged/workflow`
- Modify: all `packages/workflow-utils/src/*.ts` — update import paths
**Steps:**
1. In `package.json`, replace `"@uncaged/nerve-core": "workspace:*"` with `"@uncaged/workflow": "workspace:*"`.
2. Find-and-replace all `from "@uncaged/nerve-core"` → `from "@uncaged/workflow"` in `packages/workflow-utils/src/`.
3. Build + test:
```bash
pnpm run build && pnpm test
```
4. Commit:
```bash
git commit -am "refactor(workflow-utils): import from @uncaged/workflow
Refs #320"
```
### Task 9: Update `workflow-meta` to depend on `@uncaged/workflow`
**Objective:** Same as Task 8 but for `packages/workflow-meta/`.
**Files:**
- Modify: `packages/workflow-meta/package.json`
- Modify: all `packages/workflow-meta/src/**/*.ts`
**Steps:** Same pattern as Task 8.
### Task 10: Update adapter packages
**Objective:** Update `adapter-cursor` and `adapter-hermes` to import workflow types from `@uncaged/workflow`.
**Files:**
- Modify: `packages/adapter-cursor/src/index.ts`
- Modify: `packages/adapter-cursor/package.json`
- Modify: `packages/adapter-hermes/src/index.ts`
- Modify: `packages/adapter-hermes/package.json`
**Steps:** Same pattern — add `@uncaged/workflow` dep, update imports.
### Task 11: Update CLI package
**Objective:** Update CLI to import workflow types from `@uncaged/workflow`.
**Files:**
- Modify: `packages/cli/package.json`
- Modify: `packages/cli/src/commands/workflow.ts`
- Modify: `packages/cli/src/commands/thread.ts`
- Modify: `packages/cli/src/commands/create.ts`
- Modify: `packages/cli/src/workflow-agent-validation.ts`
- Modify: other files that import workflow types from nerve-core
**Steps:**
1. Add `"@uncaged/workflow": "workspace:*"` to `packages/cli/package.json`.
2. In each file, change workflow-related imports from `@uncaged/nerve-core` to `@uncaged/workflow`.
3. Build + test:
```bash
pnpm run build && pnpm test
```
4. Commit:
```bash
git commit -am "refactor(cli): import workflow types from @uncaged/workflow
Refs #320"
```
---
## Phase 6: Clean Up Core Re-exports
### Task 12: Remove workflow re-exports from `@uncaged/nerve-core`
**Objective:** Core no longer re-exports any workflow types. All consumers import directly from `@uncaged/workflow`.
**Files:**
- Modify: `packages/core/src/index.ts` — remove all workflow re-exports
- Modify: `packages/core/package.json` — remove `@uncaged/workflow` dependency (core no longer needs it)
**Steps:**
1. Remove all workflow-related `export` lines from `packages/core/src/index.ts`.
2. Remove `@uncaged/workflow` from core's dependencies.
3. Full build + full test:
```bash
pnpm run build && pnpm test
```
4. Run biome check:
```bash
pnpm run check
```
5. Commit:
```bash
git commit -am "refactor(core): remove workflow re-exports, clean break
Fixes #320"
```
---
## Phase 7: Verify & PR
### Task 13: Final verification
**Objective:** Full workspace build, all tests pass, biome clean.
**Steps:**
1. Clean build:
```bash
pnpm run build
```
2. Full tests:
```bash
pnpm test
```
3. Biome:
```bash
pnpm run check
```
4. Verify monorepo structure matches issue spec:
```
packages/
core/ # @uncaged/nerve-core — sense types, config (no workflow)
workflow/ # @uncaged/workflow — standalone orchestration engine
workflow-utils/ # helper roles, extract layer
workflow-meta/ # meta-workflows
daemon/ # @uncaged/nerve-daemon — sense engine + workflow integration
cli/ # @uncaged/nerve-cli
```
5. Create PR:
```bash
tea pr create --title "refactor: extract workflow engine into @uncaged/workflow" \
--description "## What
Extract workflow engine into standalone @uncaged/workflow package.
## Why
Workflow engine is now independent of sense observation — can be used standalone.
## Changes
- packages/workflow/ — new package with types, IPC, worker, manager
- packages/core/ — removed workflow types, no longer re-exports them
- packages/daemon/ — imports workflow runtime from @uncaged/workflow
- packages/cli/ — imports workflow types from @uncaged/workflow
- packages/workflow-utils/ — depends on @uncaged/workflow
- packages/workflow-meta/ — depends on @uncaged/workflow
- packages/adapter-*/ — depends on @uncaged/workflow
## Ref
Fixes #320"
```
---
## Pitfalls & Notes
1. **Worker spawn path**: `workflow-worker.ts` is spawned as a child process via `node`. After moving to `@uncaged/workflow`, the daemon needs to resolve the worker entry point from the package (e.g. `require.resolve("@uncaged/workflow/worker")`). This likely requires a separate export in `package.json` exports map.
2. **Dynamic import exception**: `workflow-worker.ts` uses dynamic `import()` for user workflow modules — this exception carries over to the new package. Add comment per CLAUDE.md conventions.
3. **IPC split**: The `ipc.ts` in daemon has both sense and workflow messages in one file with shared validation. The split needs careful handling of the `ParentToWorkerMessage` union type and `parseParentToWorkerMessage` function.
4. **No backward compat**: Per user preference, no deprecated re-exports — straight breaking change. Phase 6 removes re-exports from core entirely.
5. **`workflow-utils` may still need `nerve-core`**: If workflow-utils imports non-workflow types (like `Schema`, `ExtractFn`, `ExtractError`) from core, it will need both deps. Check carefully.
6. **Test files**: Many test files in daemon import workflow types. They need updating in Phase 5 alongside the source files.
+7 -1
View File
@@ -4,9 +4,15 @@
"engines": {
"node": ">=22.5.0"
},
"pnpm": {
"overrides": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*"
}
},
"scripts": {
"prepare": "husky",
"build": "pnpm -r run build",
"build": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build && pnpm -r --filter '!@uncaged/nerve-core' --filter '!@uncaged/nerve-store' --filter '!@uncaged/workflow' run build",
"test": "pnpm -r test",
"check": "biome check .",
"format": "biome format --write .",
+2 -1
View File
@@ -14,7 +14,8 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
"@uncaged/nerve-core": "workspace:*",
"@uncaged/workflow": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
+2 -1
View File
@@ -1,5 +1,6 @@
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import type { AgentConfig } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
export type CursorAgentMode = "plan" | "ask" | "default";
+2 -1
View File
@@ -14,7 +14,8 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
"@uncaged/nerve-core": "workspace:*",
"@uncaged/workflow": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
+2 -1
View File
@@ -1,5 +1,6 @@
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import type { AgentConfig } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
/**
* Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only
+2 -1
View File
@@ -17,12 +17,13 @@
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-daemon run build",
"pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build && pnpm --filter @uncaged/nerve-daemon run build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*",
"@uncaged/workflow": "workspace:*",
"citty": "^0.1.6",
"picomatch": "^4.0.2",
"yaml": "^2.8.3"
@@ -27,10 +27,10 @@ describe("buildWorkflowScaffold", () => {
expect(roleMainIndexTs).toContain("my-workflow started");
});
it("root index contains WorkflowDefinition import from nerve-core", () => {
it("root index contains WorkflowDefinition import from @uncaged/workflow", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("WorkflowDefinition");
expect(indexTs).toContain("@uncaged/nerve-core");
expect(indexTs).toContain("@uncaged/workflow");
});
it("root index wires moderator with ThreadContext and END", () => {
+25 -3
View File
@@ -2,9 +2,12 @@
* E2E-style tests for `nerve create workflow` and `nerve create sense`.
*/
import { execFile } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { defineCommand, runCommand } from "citty";
import { afterEach, describe, expect, it } from "vitest";
@@ -12,6 +15,23 @@ import { afterEach, describe, expect, it } from "vitest";
import { createCommand } from "../commands/create.js";
import { initCommand } from "../commands/init.js";
const execFileAsync = promisify(execFile);
const requireFromHere = createRequire(import.meta.url);
/**
* Default init pins `@uncaged/workflow` to npm `latest`, but that package is not published yet.
* Install from the monorepo workspace so `pnpm run build` can resolve workflow types.
*/
async function installWorkspaceWithLocalWorkflow(nerveRoot: string): Promise<void> {
const pkgPath = join(nerveRoot, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { dependencies: Record<string, string> };
const wfRoot = dirname(requireFromHere.resolve("@uncaged/workflow/package.json"));
pkg.dependencies["@uncaged/workflow"] = `file:${wfRoot}`;
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
await execFileAsync("pnpm", ["install", "--no-frozen-lockfile"], { cwd: nerveRoot });
await execFileAsync("pnpm", ["run", "build"], { cwd: nerveRoot });
}
const testRootCommand = defineCommand({
meta: { name: "nerve", description: "e2e-create" },
subCommands: {
@@ -128,7 +148,8 @@ describe("e2e create", () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force"]);
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
await installWorkspaceWithLocalWorkflow(nerveRoot);
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
expect(wf.exitCode).toBe(0);
@@ -153,7 +174,8 @@ describe("e2e create", () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force"]);
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
await installWorkspaceWithLocalWorkflow(nerveRoot);
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
expect(sense.exitCode).toBe(0);
+3 -2
View File
@@ -51,8 +51,9 @@ import { workflowCommand } from "../commands/workflow.js";
const require = createRequire(import.meta.url);
const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
const nerveWorkflowRoot = dirname(require.resolve("@uncaged/workflow/package.json"));
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
const workflowWorkerScript = join(nerveWorkflowRoot, "dist", "worker.js");
const nerveYamlTemplate = `senses:
counter:
@@ -274,7 +275,7 @@ export async function startTestDaemon(
}
if (!existsSync(workflowWorkerScript)) {
throw new Error(
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\`.`,
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/workflow build\`.`,
);
}
+3 -3
View File
@@ -31,8 +31,8 @@ export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
}
function buildWorkflowIndexTs(name: string): string {
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/workflow";
import { END } from "@uncaged/workflow";
import { mainRole } from "./roles/main/index.js";
@@ -56,7 +56,7 @@ export default workflow;
}
function buildWorkflowMainRoleIndexTs(name: string): string {
return `import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
return `import type { RoleResult, ThreadContext } from "@uncaged/workflow";
/**
* Main role — implement LLM calls, scripts, HTTP, etc.
+1
View File
@@ -54,6 +54,7 @@ const PACKAGE_JSON = `${JSON.stringify(
dependencies: {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
"@uncaged/workflow": "latest",
zod: "^4.3.6",
},
devDependencies: {
+2 -1
View File
@@ -1,7 +1,8 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import { isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
import { defineCommand } from "citty";
import { stringify } from "yaml";
+2 -6
View File
@@ -20,12 +20,8 @@ import type {
SenseInfo,
WorkflowStatus,
} from "@uncaged/nerve-core";
import {
DEFAULT_ENGINE_MAX_ROUNDS,
isPlainRecord,
isSenseInfo,
isWorkflowStatus,
} from "@uncaged/nerve-core";
import { isPlainRecord, isSenseInfo, isWorkflowStatus } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
import { getCliDaemonApiToken, getCliDaemonHost } from "./cli-global.js";
import { HttpTransport } from "./http-transport.js";
+2 -6
View File
@@ -6,12 +6,8 @@ import type {
SenseInfo,
WorkflowStatus,
} from "@uncaged/nerve-core";
import {
DEFAULT_ENGINE_MAX_ROUNDS,
isPlainRecord,
isSenseInfo,
isWorkflowStatus,
} from "@uncaged/nerve-core";
import { isPlainRecord, isSenseInfo, isWorkflowStatus } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
function normalizeBaseUrl(host: string): string {
const t = host.trim();
+18 -12
View File
@@ -1,19 +1,11 @@
import { parse } from "yaml";
import { type Result, err, isPlainRecord, ok, parseDurationStringToMs } from "./util.js";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type SenseConfig = {
group: string;
throttle: number | null;
timeout: number | null;
gracePeriod: number | null;
/** Polling interval (ms). When set, the sense is triggered periodically. */
interval: number | null;
/** Other sense names whose successful computes schedule this sense (kernel reverse-index). */
on: string[];
};
/**
* Workflow queue/runtime limits parsed from nerve.yaml.
* Shapes match the standalone workflow package — core must not depend on it (#320).
*/
export type DropOverflowConfig = {
concurrency: number;
overflow: "drop";
@@ -27,6 +19,20 @@ export type QueueOverflowConfig = {
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
/** Engine-wide fallback when nerve.yaml omits max_rounds (keep in sync with workflow package default). */
export const DEFAULT_ENGINE_MAX_ROUNDS = 100;
export type SenseConfig = {
group: string;
throttle: number | null;
timeout: number | null;
gracePeriod: number | null;
/** Polling interval (ms). When set, the sense is triggered periodically. */
interval: number | null;
/** Other sense names whose successful computes schedule this sense (kernel reverse-index). */
on: string[];
};
/** Optional HTTP control plane. When `port` is null, the HTTP server is not started. */
export type NerveApiConfig = {
port: number | null;
-18
View File
@@ -1,8 +1,5 @@
export type {
SenseConfig,
DropOverflowConfig,
QueueOverflowConfig,
WorkflowConfig,
NerveApiConfig,
AgentConfig,
ExtractConfig,
@@ -12,21 +9,6 @@ export type {
export type { SenseInfo } from "./sense.js";
export type { SenseComputeFn, SenseModule } from "./sense.js";
export { senseTriggerLabels } from "./sense.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type { Schema, ExtractFn } from "./agent.js";
export { ExtractError } from "./agent.js";
export type { Result } from "./util.js";
+2 -1
View File
@@ -22,10 +22,11 @@
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build",
"pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*",
"yaml": "^2.8.3"
-1
View File
@@ -11,7 +11,6 @@ export default defineConfig({
entry: {
index: "src/index.ts",
"sense-worker": "src/sense-worker.ts",
"workflow-worker": "src/workflow-worker.ts",
"experimental-warning-suppression": "src/experimental-warning-suppression.ts",
},
},
@@ -10,7 +10,8 @@
import { EventEmitter } from "node:events";
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { WorkflowConfig } from "@uncaged/workflow";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockChild = EventEmitter & {
@@ -62,7 +63,7 @@ vi.mock("node:child_process", () => ({
}),
}));
const { createWorkflowManager } = await import("../workflow-manager.js");
const { createWorkflowManager } = await import("@uncaged/workflow");
function makeConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig {
return {
@@ -15,7 +15,8 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { WorkflowConfig } from "@uncaged/workflow";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockChild = EventEmitter & {
@@ -67,7 +68,7 @@ vi.mock("node:child_process", () => ({
}),
}));
const { createWorkflowManager } = await import("../workflow-manager.js");
const { createWorkflowManager } = await import("@uncaged/workflow");
const { createKernel } = await import("../kernel.js");
function makeWfConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig {
@@ -1,7 +1,7 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createWorkerRuntime } from "@uncaged/workflow";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkerRuntime } from "../worker-runtime.js";
const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
const echoWorkerPath = join(fixturesDir, "echo-worker.js");
@@ -61,7 +61,7 @@ vi.mock("node:child_process", () => ({
}));
// Import after mock is set up
const { createWorkflowManager } = await import("../workflow-manager.js");
const { createWorkflowManager } = await import("@uncaged/workflow");
// ---------------------------------------------------------------------------
// Helpers
+1 -1
View File
@@ -1,6 +1,6 @@
import type { HealthInfo, SenseInfo, WorkflowStatus } from "@uncaged/nerve-core";
import type { WorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "@uncaged/workflow";
export type DaemonHandlerBundle = {
health: () => HealthInfo;
+3 -3
View File
@@ -12,7 +12,7 @@ export type {
ResumeThreadMessage,
ThreadEventMessage,
WorkflowErrorMessage,
ThreadWorkflowMessageMessage,
ThreadWorkflowMessage,
} from "./ipc.js";
export { loadSenseModule, executeCompute, readState, writeState } from "./sense-runtime.js";
@@ -45,5 +45,5 @@ export type {
WorkflowRunStatus,
} from "@uncaged/nerve-store";
export { createWorkflowManager } from "./workflow-manager.js";
export type { WorkflowManager } from "./workflow-manager.js";
export { createWorkflowManager } from "@uncaged/workflow";
export type { WorkflowManager } from "@uncaged/workflow";
+40 -218
View File
@@ -1,10 +1,30 @@
/**
* IPC message types for parent (kernel) ↔ sense worker communication.
* Protocol per RFC §5.2: hub-and-spoke, all messages through engine.
*
* Workflow worker IPC types and parsers live in `@uncaged/workflow`.
*/
import type { Result, SenseTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok, parseSenseTrigger } from "@uncaged/nerve-core";
import type {
KillThreadMessage,
ResumeThreadMessage,
StartThreadMessage,
ThreadEventMessage,
ThreadWorkflowMessage,
WorkflowErrorMessage,
} from "@uncaged/workflow";
import { parseWorkflowParentMessage, parseWorkflowWorkerToParentMessage } from "@uncaged/workflow";
export type {
KillThreadMessage,
ResumeThreadMessage,
StartThreadMessage,
ThreadEventMessage,
ThreadWorkflowMessage,
WorkflowErrorMessage,
} from "@uncaged/workflow";
/** Parent → Worker: trigger one compute cycle for a sense */
export type ComputeMessage = {
@@ -22,40 +42,6 @@ export type HealthRequestMessage = {
type: "health-request";
};
// ---------------------------------------------------------------------------
// Workflow IPC messages (RFC-002 §5.2)
// ---------------------------------------------------------------------------
/** Parent → Workflow Worker: start a new thread */
export type StartThreadMessage = {
type: "start-thread";
runId: string;
workflow: string;
prompt: string;
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
maxRounds: number;
/** When true, roles may skip side effects (thread-level hint on the start frame). */
dryRun: boolean;
};
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
export type ResumeThreadMessage = {
type: "resume-thread";
runId: string;
/** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
/** Safety-valve: max moderator rounds for this thread. */
maxRounds: number;
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
dryRun: boolean;
};
/** Parent → Workflow Worker: kill a specific running thread */
export type KillThreadMessage = {
type: "kill-thread";
runId: string;
};
/** Union of all messages the parent sends to a worker */
export type ParentToWorkerMessage =
| ComputeMessage
@@ -92,46 +78,6 @@ export type HealthResponseMessage = {
inFlightCount: number;
};
// ---------------------------------------------------------------------------
// Workflow Worker → Parent messages (RFC-002 §5.2)
// ---------------------------------------------------------------------------
/** Valid lifecycle event types for a workflow thread. */
export type ThreadEventType =
| "queued"
| "started"
| "step_complete"
| "completed"
| "failed"
| "killed";
/**
* Workflow Worker → Parent: a thread lifecycle event.
*/
export type ThreadEventMessage = {
type: "thread-event";
runId: string;
eventType: ThreadEventType;
payload: unknown;
};
/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */
export type WorkflowErrorMessage = {
type: "workflow-error";
runId: string;
error: string;
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
exitCode: number;
};
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
export type ThreadWorkflowMessageMessage = {
type: "thread-workflow-message";
runId: string;
/** The WorkflowMessage produced by the role — persisted for crash recovery. */
message: { role: string; content: string; meta: unknown; timestamp: number };
};
/** Union of all messages a worker sends to the parent */
export type WorkerToParentMessage =
| ComputeResultMessage
@@ -140,7 +86,7 @@ export type WorkerToParentMessage =
| HealthResponseMessage
| ThreadEventMessage
| WorkflowErrorMessage
| ThreadWorkflowMessageMessage;
| ThreadWorkflowMessage;
const PARENT_MSG_TYPES = new Set([
"compute",
@@ -151,24 +97,6 @@ const PARENT_MSG_TYPES = new Set([
"kill-thread",
]);
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'start-thread' message missing string 'runId'";
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
return null;
}
function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'";
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
if (typeof obj.maxRounds !== "number")
return "'resume-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
return null;
}
function parseParentCompute(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
if (typeof obj.sense !== "string") {
return err(new Error("IPC 'compute' message missing string 'sense' field"));
@@ -176,40 +104,6 @@ function parseParentCompute(obj: Record<string, unknown>): Result<ParentToWorker
return ok({ type: "compute", sense: obj.sense });
}
function parseParentStartThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
const errMsg = validateStartThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
// Field types are validated above; `Record<string, unknown>` values stay `unknown` to TypeScript.
return ok({
type: "start-thread",
runId: obj.runId,
workflow: obj.workflow,
prompt: obj.prompt,
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as StartThreadMessage);
}
function parseParentResumeThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
const errMsg = validateResumeThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
// Elements are validated as plain objects by the kernel; trust the wire shape here.
return ok({
type: "resume-thread",
runId: obj.runId,
messages: obj.messages as ResumeThreadMessage["messages"],
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as ResumeThreadMessage);
}
function parseParentKillThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("'kill-thread' message missing string 'runId'"));
}
return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage);
}
/** Validate and parse an unknown IPC message received from the parent process. */
export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage> {
if (!isPlainRecord(raw)) {
@@ -230,11 +124,14 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
case "health-request":
return ok({ type: "health-request" });
case "start-thread":
return parseParentStartThread(obj);
case "resume-thread":
return parseParentResumeThread(obj);
case "kill-thread":
return parseParentKillThread(obj);
case "kill-thread": {
const wf = parseWorkflowParentMessage(raw);
if (!wf.ok) {
return wf;
}
return ok(wf.value as ParentToWorkerMessage);
}
default:
return err(new Error(`Unhandled IPC message type: "${obj.type}"`));
}
@@ -299,55 +196,11 @@ function parseHealthResponseMsg(obj: Record<string, unknown>): Result<WorkerToPa
});
}
function isThreadEventType(value: string): value is ThreadEventType {
switch (value) {
case "queued":
case "started":
case "step_complete":
case "completed":
case "failed":
case "killed":
return true;
default:
return false;
}
}
function parseThreadEventMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
}
if (typeof obj.eventType !== "string" || !isThreadEventType(obj.eventType)) {
return err(
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
);
}
if (!("payload" in obj)) {
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
}
return ok({
type: "thread-event",
runId: obj.runId,
eventType: obj.eventType,
payload: obj.payload,
});
}
function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
}
if (typeof obj.error !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
}
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
return ok({
type: "workflow-error",
runId: obj.runId,
error: obj.error,
exitCode,
});
}
const WORKFLOW_WORKER_MSG_TYPES = new Set([
"thread-event",
"workflow-error",
"thread-workflow-message",
]);
const WORKER_MSG_TYPES = new Set([
"compute-result",
@@ -359,41 +212,6 @@ const WORKER_MSG_TYPES = new Set([
"thread-workflow-message",
]);
function parseThreadWorkflowMessageMsg(
obj: Record<string, unknown>,
): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
}
if (!isPlainRecord(obj.message)) {
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
}
const msg = obj.message;
if (typeof msg.role !== "string") {
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
}
if (typeof msg.content !== "string") {
return err(
new Error("Worker 'thread-workflow-message' message missing string 'content' field"),
);
}
if (typeof msg.timestamp !== "number") {
return err(
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
);
}
return ok({
type: "thread-workflow-message",
runId: obj.runId,
message: {
role: msg.role,
content: msg.content,
meta: "meta" in msg ? msg.meta : undefined,
timestamp: msg.timestamp,
},
});
}
/** Validate and parse an unknown IPC message received from a worker process. */
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
if (!isPlainRecord(raw)) {
@@ -406,11 +224,15 @@ export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage>
if (!WORKER_MSG_TYPES.has(obj.type)) {
return err(new Error(`Unknown worker IPC message type: "${obj.type}"`));
}
if (WORKFLOW_WORKER_MSG_TYPES.has(obj.type)) {
const wf = parseWorkflowWorkerToParentMessage(raw);
if (!wf.ok) {
return wf;
}
return ok(wf.value as WorkerToParentMessage);
}
if (obj.type === "compute-result") return parseComputeResultMsg(obj);
if (obj.type === "error") return parseErrorMsg(obj);
if (obj.type === "health-response") return parseHealthResponseMsg(obj);
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj);
return ok({ type: "ready" });
}
+1 -1
View File
@@ -9,7 +9,7 @@ import type { NerveConfig } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { LogStore } from "@uncaged/nerve-store";
import type { WorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "@uncaged/workflow";
export type KernelFileWatchDeps = {
nerveRoot: string;
+2 -2
View File
@@ -18,6 +18,8 @@ import {
import { createLogStore } from "@uncaged/nerve-store";
import type { LogStore } from "@uncaged/nerve-store";
import { createWorkflowManager } from "@uncaged/workflow";
import type { WorkflowManager } from "@uncaged/workflow";
import { createDaemonHandlers } from "./daemon-handlers.js";
import { createDaemonIpcServer } from "./daemon-ipc.js";
import type { DaemonIpcServer } from "./daemon-ipc.js";
@@ -36,8 +38,6 @@ import {
import { createSenseScheduler } from "./sense-scheduler.js";
import type { SenseScheduler } from "./sense-scheduler.js";
import { createSenseWorkerPool, resolveWorkerScript } from "./worker-pool.js";
import { createWorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "./workflow-manager.js";
export type KernelHealth = {
uptime: number;
+2 -2
View File
@@ -5,12 +5,12 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ComputeMessage } from "./ipc.js";
import {
createWorkerRuntime,
formatCapturedStderrTail,
formatChildExitSummary,
} from "./worker-runtime.js";
} from "@uncaged/workflow";
import type { ComputeMessage } from "./ipc.js";
export function resolveWorkerScript(): string {
const __filename = fileURLToPath(import.meta.url);
+1
View File
@@ -15,6 +15,7 @@
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/workflow": "workspace:*",
"@uncaged/nerve-workflow-utils": "workspace:*",
"zod": "^4.3.6"
},
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole, decorateRole, onFail, withDryRun } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { z } from "zod";
export const committerMetaSchema = z.object({
+1
View File
@@ -15,6 +15,7 @@
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/workflow": "workspace:*",
"@uncaged/nerve-workflow-utils": "workspace:*",
"zod": "^4.3.6"
},
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { z } from "zod";
export const reviewerMetaSchema = z.object({
+1
View File
@@ -15,6 +15,7 @@
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/workflow": "workspace:*",
"@uncaged/nerve-role-committer": "workspace:*",
"@uncaged/nerve-role-reviewer": "workspace:*",
"@uncaged/nerve-workflow-utils": "workspace:*",
@@ -1,7 +1,7 @@
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
import { createCommitterRole } from "@uncaged/nerve-role-committer";
import { createReviewerRole } from "@uncaged/nerve-role-reviewer";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, WorkflowDefinition } from "@uncaged/workflow";
import { moderator } from "./moderator.js";
import type { SenseMeta } from "./moderator.js";
@@ -1,7 +1,7 @@
import { END } from "@uncaged/nerve-core";
import type { Moderator } from "@uncaged/nerve-core";
import type { CommitterMeta } from "@uncaged/nerve-role-committer";
import type { ReviewerMeta } from "@uncaged/nerve-role-reviewer";
import { END } from "@uncaged/workflow";
import type { Moderator } from "@uncaged/workflow";
import type { CoderMeta } from "./roles/coder.js";
import type { PlannerMeta } from "./roles/planner.js";
import type { TesterMeta } from "./roles/tester.js";
@@ -1,6 +1,6 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { z } from "zod";
export const coderMetaSchema = z.object({
@@ -1,6 +1,6 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { z } from "zod";
export const plannerMetaSchema = z.object({
@@ -1,6 +1,6 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { z } from "zod";
export const testerMetaSchema = z.object({
@@ -1,7 +1,7 @@
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
import { createCommitterRole } from "@uncaged/nerve-role-committer";
import { createReviewerRole } from "@uncaged/nerve-role-reviewer";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, WorkflowDefinition } from "@uncaged/workflow";
import { moderator } from "./moderator.js";
import type { WorkflowMeta } from "./moderator.js";
@@ -1,7 +1,7 @@
import { END } from "@uncaged/nerve-core";
import type { Moderator } from "@uncaged/nerve-core";
import type { CommitterMeta } from "@uncaged/nerve-role-committer";
import type { ReviewerMeta } from "@uncaged/nerve-role-reviewer";
import { END } from "@uncaged/workflow";
import type { Moderator } from "@uncaged/workflow";
import type { CoderMeta } from "./roles/coder.js";
import type { PlannerMeta } from "./roles/planner.js";
import type { TesterMeta } from "./roles/tester.js";
@@ -1,6 +1,6 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { z } from "zod";
export const coderMetaSchema = z.object({
@@ -1,6 +1,6 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { z } from "zod";
export const plannerMetaSchema = z.object({
@@ -1,6 +1,6 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { z } from "zod";
export const testerMetaSchema = z.object({
+1
View File
@@ -24,6 +24,7 @@
"@uncaged/nerve-adapter-cursor": "workspace:*",
"@uncaged/nerve-adapter-hermes": "workspace:*",
"@uncaged/nerve-core": "workspace:*",
"@uncaged/workflow": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { START, type ThreadContext } from "@uncaged/nerve-core";
import { START, type ThreadContext } from "@uncaged/workflow";
import { createLlmAdapter } from "../create-llm-adapter.js";
@@ -4,8 +4,8 @@ import type {
RoleMeta,
ThreadContext,
WorkflowDefinition,
} from "@uncaged/nerve-core";
import { END, START } from "@uncaged/nerve-core";
} from "@uncaged/workflow";
import { END, START } from "@uncaged/workflow";
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { Role, ThreadContext } from "@uncaged/nerve-core";
import type { Role, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/nerve-core";
import { START } from "@uncaged/workflow";
import { decorateRole, onFail, withDryRun } from "../role-decorators.js";
type TestMeta = Record<string, unknown> & { ok: boolean };
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { START, type ThreadContext } from "@uncaged/nerve-core";
import { START, type ThreadContext } from "@uncaged/workflow";
import { createCursorRole } from "../role-cursor.js";
import { createHermesRole } from "../role-hermes.js";
@@ -1,4 +1,4 @@
import type { AgentFn, ThreadContext } from "@uncaged/nerve-core";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { formatLlmError } from "./shared/format-error.js";
import { chatCompletionText } from "./shared/llm-chat.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import type { z } from "zod";
import { extractMetaOrThrow } from "./shared/extract-fn.js";
+2 -1
View File
@@ -1,5 +1,6 @@
import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor";
import type { Role, SpawnEnv } from "@uncaged/nerve-core";
import type { SpawnEnv } from "@uncaged/nerve-core";
import type { Role } from "@uncaged/workflow";
import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
@@ -1,4 +1,4 @@
import type { Role, ThreadContext } from "@uncaged/nerve-core";
import type { Role, ThreadContext } from "@uncaged/workflow";
// ---------------------------------------------------------------------------
// Decorator types
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Role } from "@uncaged/nerve-core";
import type { Role } from "@uncaged/workflow";
import type { HermesRoleDefaults, HermesRoleRequired } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Role } from "@uncaged/nerve-core";
import type { Role } from "@uncaged/workflow";
import type { LlmMessage, LlmRoleDefaults, LlmRoleRequired } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Role } from "@uncaged/nerve-core";
import type { Role } from "@uncaged/workflow";
import type { LlmMessage, ReActRoleDefaults, ReActRoleRequired } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
+2 -1
View File
@@ -1,4 +1,5 @@
import type { SpawnEnv, ThreadContext } from "@uncaged/nerve-core";
import type { SpawnEnv } from "@uncaged/nerve-core";
import type { ThreadContext } from "@uncaged/workflow";
import type { z } from "zod";
import type { LlmProvider } from "./shared/llm-extract.js";
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@uncaged/workflow",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./public-types": {
"types": "./dist/public-types.d.ts",
"default": "./dist/public-types.js"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build && pnpm run build:public-types",
"build:public-types": "tsc -p tsconfig.public-types.json"
},
"dependencies": {
"@uncaged/nerve-core": "^0.5.0",
"@uncaged/nerve-store": "^0.5.0"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
worker: "src/worker.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
+12
View File
@@ -0,0 +1,12 @@
export type DropOverflowConfig = {
concurrency: number;
overflow: "drop";
};
export type QueueOverflowConfig = {
concurrency: number;
overflow: "queue";
maxQueue: number;
};
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
@@ -0,0 +1,23 @@
/**
* Patches `process.emit` so `ExperimentalWarning` (e.g. from `node:sqlite`) is not
* forwarded to the default handler. Other warning types are unchanged.
*
* Import this module before any code that loads `node:sqlite`.
*/
const WARNING_EVENT = "warning";
const EXPERIMENTAL_WARNING_NAME = "ExperimentalWarning";
type EmitFn = typeof process.emit;
const originalEmit = process.emit.bind(process) as EmitFn;
process.emit = ((event: string | symbol, ...args: unknown[]): boolean => {
if (event === WARNING_EVENT) {
const w = args[0];
if (w instanceof Error && w.name === EXPERIMENTAL_WARNING_NAME) {
return false;
}
}
return Reflect.apply(originalEmit, process, [event, ...args]) as boolean;
}) as EmitFn;
+53
View File
@@ -0,0 +1,53 @@
// @uncaged/workflow — standalone workflow orchestration engine
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./types.js";
export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig } from "./config.js";
export type {
KillThreadMessage,
ResumeThreadMessage,
StartThreadMessage,
WorkflowParentToWorkerMessage,
WorkflowWorkerShutdownMessage,
ThreadEventType,
ThreadLifecycleEvent,
ThreadEventMessage,
WorkflowErrorMessage,
ThreadWorkflowMessage,
WorkflowWorkerToParentMessage,
WorkflowWorkerReadyMessage,
WorkflowChildToParentMessage,
WorkflowWorkerOutboundMessage,
} from "./ipc.js";
export {
parseWorkflowParentMessage,
parseWorkflowWorkerToParentMessage,
parseWorkflowChildMessage,
} from "./ipc.js";
export { WORKFLOW_WORKER_PATH } from "./paths.js";
export {
createWorkerRuntime,
formatCapturedStderrTail,
formatChildExitSummary,
} from "./worker-runtime.js";
export type { WorkerDrainOpts, WorkerRuntime, WorkerRuntimeConfig } from "./worker-runtime.js";
export { createWorkflowManager } from "./manager.js";
export type { WorkflowLaunchParams, WorkflowManager } from "./manager.js";
+314
View File
@@ -0,0 +1,314 @@
/**
* IPC message types for parent (kernel) ↔ workflow worker communication.
* Protocol per RFC-002 §5.2.
*/
import type { Result } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
/** Parent → Workflow Worker: start a new thread */
export type StartThreadMessage = {
type: "start-thread";
runId: string;
workflow: string;
prompt: string;
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
maxRounds: number;
/** When true, roles may skip side effects (thread-level hint on the start frame). */
dryRun: boolean;
};
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
export type ResumeThreadMessage = {
type: "resume-thread";
runId: string;
/** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
/** Safety-valve: max moderator rounds for this thread. */
maxRounds: number;
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
dryRun: boolean;
};
/** Parent → Workflow Worker: kill a specific running thread */
export type KillThreadMessage = {
type: "kill-thread";
runId: string;
};
/** Parent → Workflow Worker: graceful shutdown (same wire shape as sense worker). */
export type WorkflowWorkerShutdownMessage = {
type: "shutdown";
};
/** Messages the parent sends to a workflow worker process */
export type WorkflowParentToWorkerMessage =
| StartThreadMessage
| ResumeThreadMessage
| KillThreadMessage
| WorkflowWorkerShutdownMessage;
/** Valid lifecycle event types for a workflow thread. */
export type ThreadEventType =
| "queued"
| "started"
| "step_complete"
| "completed"
| "failed"
| "killed";
/** Alias — lifecycle channel uses `ThreadEventType` values. */
export type ThreadLifecycleEvent = ThreadEventType;
/**
* Workflow Worker → Parent: a thread lifecycle event.
*/
export type ThreadEventMessage = {
type: "thread-event";
runId: string;
eventType: ThreadEventType;
payload: unknown;
};
/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */
export type WorkflowErrorMessage = {
type: "workflow-error";
runId: string;
error: string;
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
exitCode: number;
};
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
export type ThreadWorkflowMessage = {
type: "thread-workflow-message";
runId: string;
/** The WorkflowMessage produced by the role — persisted for crash recovery. */
message: { role: string; content: string; meta: unknown; timestamp: number };
};
/** Messages a workflow worker sends to the parent (subset of full worker IPC). */
export type WorkflowWorkerToParentMessage =
| ThreadEventMessage
| WorkflowErrorMessage
| ThreadWorkflowMessage;
export type WorkflowWorkerReadyMessage = { type: "ready" };
/** Messages a workflow child may emit on IPC (including bootstrap ready). */
export type WorkflowChildToParentMessage =
| WorkflowWorkerReadyMessage
| WorkflowWorkerToParentMessage;
/** Messages a workflow worker process may send upstream (ready + persisted workflow IPC). */
export type WorkflowWorkerOutboundMessage =
| WorkflowWorkerReadyMessage
| WorkflowWorkerToParentMessage;
const WORKFLOW_PARENT_MSG_TYPES = new Set([
"start-thread",
"resume-thread",
"kill-thread",
"shutdown",
]);
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'start-thread' message missing string 'runId'";
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
return null;
}
function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'";
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
if (typeof obj.maxRounds !== "number")
return "'resume-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
return null;
}
function parseParentStartThread(obj: Record<string, unknown>): Result<StartThreadMessage> {
const errMsg = validateStartThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
return ok({
type: "start-thread",
runId: obj.runId,
workflow: obj.workflow,
prompt: obj.prompt,
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as StartThreadMessage);
}
function parseParentResumeThread(obj: Record<string, unknown>): Result<ResumeThreadMessage> {
const errMsg = validateResumeThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
return ok({
type: "resume-thread",
runId: obj.runId,
messages: obj.messages as ResumeThreadMessage["messages"],
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as ResumeThreadMessage);
}
function parseParentKillThread(obj: Record<string, unknown>): Result<KillThreadMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("'kill-thread' message missing string 'runId'"));
}
return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage);
}
/** Validate and parse an unknown IPC message for a workflow worker process. */
export function parseWorkflowParentMessage(raw: unknown): Result<WorkflowParentToWorkerMessage> {
if (!isPlainRecord(raw)) {
return err(new Error("IPC message is not an object"));
}
const obj = raw;
if (typeof obj.type !== "string") {
return err(new Error("IPC message missing string 'type' field"));
}
if (!WORKFLOW_PARENT_MSG_TYPES.has(obj.type)) {
return err(new Error(`Unknown workflow IPC message type: "${obj.type}"`));
}
switch (obj.type) {
case "shutdown":
return ok({ type: "shutdown" });
case "start-thread":
return parseParentStartThread(obj);
case "resume-thread":
return parseParentResumeThread(obj);
case "kill-thread":
return parseParentKillThread(obj);
default:
return err(new Error(`Unhandled workflow IPC message type: "${obj.type}"`));
}
}
function isThreadEventType(value: string): value is ThreadEventType {
switch (value) {
case "queued":
case "started":
case "step_complete":
case "completed":
case "failed":
case "killed":
return true;
default:
return false;
}
}
function parseThreadEventMsg(obj: Record<string, unknown>): Result<ThreadEventMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
}
if (typeof obj.eventType !== "string" || !isThreadEventType(obj.eventType)) {
return err(
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
);
}
if (!("payload" in obj)) {
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
}
return ok({
type: "thread-event",
runId: obj.runId,
eventType: obj.eventType,
payload: obj.payload,
});
}
function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkflowErrorMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
}
if (typeof obj.error !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
}
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
return ok({
type: "workflow-error",
runId: obj.runId,
error: obj.error,
exitCode,
});
}
function parseThreadWorkflowMessageMsg(
obj: Record<string, unknown>,
): Result<ThreadWorkflowMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
}
if (!isPlainRecord(obj.message)) {
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
}
const msg = obj.message;
if (typeof msg.role !== "string") {
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
}
if (typeof msg.content !== "string") {
return err(
new Error("Worker 'thread-workflow-message' message missing string 'content' field"),
);
}
if (typeof msg.timestamp !== "number") {
return err(
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
);
}
return ok({
type: "thread-workflow-message",
runId: obj.runId,
message: {
role: msg.role,
content: msg.content,
meta: "meta" in msg ? msg.meta : null,
timestamp: msg.timestamp,
},
});
}
const WORKFLOW_WORKER_MSG_TYPES = new Set([
"thread-event",
"workflow-error",
"thread-workflow-message",
]);
/** Validate and parse workflow worker → parent IPC messages. */
export function parseWorkflowWorkerToParentMessage(
raw: unknown,
): Result<WorkflowWorkerToParentMessage> {
if (!isPlainRecord(raw)) {
return err(new Error("Worker IPC message is not an object"));
}
const obj = raw;
if (typeof obj.type !== "string") {
return err(new Error("Worker IPC message missing string 'type' field"));
}
if (!WORKFLOW_WORKER_MSG_TYPES.has(obj.type)) {
return err(new Error(`Unknown workflow worker IPC message type: "${obj.type}"`));
}
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
return parseThreadWorkflowMessageMsg(obj);
}
/** Parse messages from a workflow worker child (thread IPC + optional `ready`). */
export function parseWorkflowChildMessage(raw: unknown): Result<WorkflowChildToParentMessage> {
if (!isPlainRecord(raw)) {
return err(new Error("Worker IPC message is not an object"));
}
const obj = raw;
if (typeof obj.type !== "string") {
return err(new Error("Worker IPC message missing string 'type' field"));
}
if (obj.type === "ready") {
return ok({ type: "ready" });
}
return parseWorkflowWorkerToParentMessage(raw);
}
@@ -2,15 +2,13 @@
* Pure helpers and IPC branching for workflow-manager (keeps workflow-manager.ts lean).
*/
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { WorkflowMessage } from "@uncaged/nerve-core";
import { START, isPlainRecord } from "@uncaged/nerve-core";
import { isPlainRecord } from "@uncaged/nerve-core";
import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store";
import type { ResumeThreadMessage, ThreadEventMessage } from "./ipc.js";
import type { WorkerToParentMessage } from "./ipc.js";
import type { WorkflowChildToParentMessage } from "./ipc.js";
import type { WorkflowMessage } from "./types.js";
import { START } from "./types.js";
export type PendingThread = {
runId: string;
@@ -33,7 +31,7 @@ export const WORKFLOW_WORKER_RESPAWN = {
} as const;
/**
* Worker shutdown timeout must stay in sync with SHUTDOWN_TIMEOUT_MS in workflow-worker.ts.
* Worker shutdown timeout must stay in sync with shutdown handling in `worker.ts`.
* The drain timeout passed to drainAndRespawn must be >= this value so the worker has
* enough time to finish in-flight threads before the parent force-kills it.
*/
@@ -79,12 +77,6 @@ export function ensureThreadMessagesWithStart(
return [start, ...mapped];
}
export function resolveWorkflowWorkerScript(): string {
const __filename = fileURLToPath(import.meta.url);
const __dir = dirname(__filename);
return join(__dir, "workflow-worker.js");
}
export function mapWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
const map: Record<string, WorkflowRunStatus> = {
started: "started",
@@ -223,9 +215,13 @@ export type WorkflowManagerMessageDeps = {
export function dispatchWorkflowWorkerMessage(
workflowName: string,
msg: WorkerToParentMessage,
msg: WorkflowChildToParentMessage,
deps: WorkflowManagerMessageDeps,
): void {
if (msg.type === "ready") {
return;
}
if (msg.type === "thread-event") {
deps.handleThreadEvent(workflowName, msg);
return;
@@ -247,10 +243,5 @@ export function dispatchWorkflowWorkerMessage(
`[workflow-manager] workflow-error for runId "${msg.runId}" in "${workflowName}": ${msg.error}\n`,
);
deps.onWorkflowRoleError(workflowName, msg.runId, msg.error, msg.exitCode);
return;
}
if (msg.type === "error") {
process.stderr.write(`[workflow-manager] error from "${workflowName}" worker: ${msg.error}\n`);
}
}
@@ -6,16 +6,13 @@
* Concurrency and overflow (drop/queue) are enforced here in the parent process.
*/
import type { NerveConfig, WorkflowConfig, WorkflowStatus } from "@uncaged/nerve-core";
import type { NerveConfig, WorkflowStatus } from "@uncaged/nerve-core";
import type { WorkflowConfig } from "./config.js";
import type { LogStore } from "@uncaged/nerve-store";
import type { KillThreadMessage, StartThreadMessage, ThreadEventMessage } from "./ipc.js";
import { parseWorkerMessage } from "./ipc.js";
import {
createWorkerRuntime,
formatCapturedStderrTail,
formatChildExitSummary,
} from "./worker-runtime.js";
import { parseWorkflowChildMessage } from "./ipc.js";
import {
DEFAULT_MAX_QUEUE,
WORKER_SHUTDOWN_TIMEOUT_MS,
@@ -25,8 +22,13 @@ import {
dispatchWorkflowWorkerMessage,
extractExitCodeFromPayload,
recoverThreadsFromStore,
resolveWorkflowWorkerScript,
} from "./workflow-manager-support.js";
} from "./manager-support.js";
import { WORKFLOW_WORKER_PATH } from "./paths.js";
import {
createWorkerRuntime,
formatCapturedStderrTail,
formatChildExitSummary,
} from "./worker-runtime.js";
export type WorkflowLaunchParams = {
prompt: string;
@@ -73,7 +75,7 @@ export function createWorkflowManager(
initialConfig: NerveConfig,
logStore: LogStore,
): WorkflowManager {
const workerScript = resolveWorkflowWorkerScript();
const workerScript = WORKFLOW_WORKER_PATH;
/**
* Default drain timeout must be at least WORKER_SHUTDOWN_TIMEOUT_MS so the worker
@@ -309,7 +311,7 @@ export function createWorkflowManager(
}
function handleWorkerMessage(workflowName: string, raw: unknown): void {
const result = parseWorkerMessage(raw);
const result = parseWorkflowChildMessage(raw);
if (!result.ok) {
process.stderr.write(
`[workflow-manager] invalid message from "${workflowName}" worker: ${result.error.message}\n`,
+5
View File
@@ -0,0 +1,5 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
/** Resolved path to the workflow child-process entry (`worker.js` adjacent to this module in dist). */
export const WORKFLOW_WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.js");
+22
View File
@@ -0,0 +1,22 @@
/**
* Narrow surface for `@uncaged/nerve-core` — workflow automaton types + config only.
* Keeps core's declaration graph free of IPC / manager / store.
*/
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./types.js";
export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig } from "./config.js";
+17
View File
@@ -0,0 +1,17 @@
/**
* Worker-process signal handling (fork IPC children only).
* Worker entrypoints import this module — not worker-runtime.ts (parent/kernel code).
*/
/**
* Forked workers inherit the parent's process group. In foreground `nerve dev`,
* terminal-driven SIGINT/SIGTERM is delivered to the whole group, so workers can exit
* on the default handler before the kernel sends `{ type: "shutdown" }` over IPC.
* Swallow these in worker processes so the parent coordinates shutdown (issue #55).
* Only call when `process.send` is defined (fork IPC); standalone `node …-worker.js` keeps default Ctrl+C behaviour.
*/
export function ignoreSessionBroadcastSignals(): void {
const swallow = (): void => {};
process.on("SIGINT", swallow);
process.on("SIGTERM", swallow);
}
@@ -14,6 +14,14 @@ import "./experimental-warning-suppression.js";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { isPlainRecord } from "@uncaged/nerve-core";
import type {
ThreadEventType,
ThreadWorkflowMessage,
WorkflowWorkerOutboundMessage,
} from "./ipc.js";
import { parseWorkflowParentMessage } from "./ipc.js";
import type {
RoleMeta,
RoleStep,
@@ -21,22 +29,15 @@ import type {
ThreadContext,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
import type {
ThreadEventType,
ThreadWorkflowMessageMessage,
WorkerToParentMessage,
} from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
} from "./types.js";
import { END, START } from "./types.js";
import { ignoreSessionBroadcastSignals } from "./worker-signals.js";
// ---------------------------------------------------------------------------
// IPC helpers
// ---------------------------------------------------------------------------
function send(msg: WorkerToParentMessage): void {
function send(msg: WorkflowWorkerOutboundMessage): void {
if (process.send) {
process.send(msg);
}
@@ -55,7 +56,7 @@ function sendWorkflowError(runId: string, error: string, exitCode = 1): void {
}
function sendWorkflowMessage(runId: string, message: WorkflowMessage): void {
const msg: ThreadWorkflowMessageMessage = {
const msg: ThreadWorkflowMessage = {
type: "thread-workflow-message",
runId,
message: {
@@ -334,7 +335,7 @@ function handleMessage(
killFlags: Map<string, KillFlag>,
shuttingDown: { value: boolean },
): void {
const parseResult = parseParentMessage(raw);
const parseResult = parseWorkflowParentMessage(raw);
if (!parseResult.ok) {
process.stderr.write(`[workflow-worker] Invalid IPC message: ${parseResult.error.message}\n`);
return;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"composite": false,
"noEmit": false
},
"include": ["src/types.ts", "src/config.ts", "src/public-types.ts"]
}
+44
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@uncaged/nerve-core': workspace:*
'@uncaged/nerve-store': workspace:*
importers:
.:
@@ -26,6 +30,9 @@ importers:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
'@uncaged/workflow':
specifier: workspace:*
version: link:../workflow
devDependencies:
'@rslib/core':
specifier: ^0.21.3
@@ -42,6 +49,9 @@ importers:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
'@uncaged/workflow':
specifier: workspace:*
version: link:../workflow
devDependencies:
'@rslib/core':
specifier: ^0.21.3
@@ -61,6 +71,9 @@ importers:
'@uncaged/nerve-store':
specifier: workspace:*
version: link:../store
'@uncaged/workflow':
specifier: workspace:*
version: link:../workflow
citty:
specifier: ^0.1.6
version: 0.1.6
@@ -108,6 +121,9 @@ importers:
'@uncaged/nerve-store':
specifier: workspace:*
version: link:../store
'@uncaged/workflow':
specifier: workspace:*
version: link:../workflow
yaml:
specifier: ^2.8.3
version: 2.8.3
@@ -161,6 +177,9 @@ importers:
'@uncaged/nerve-workflow-utils':
specifier: workspace:*
version: link:../workflow-utils
'@uncaged/workflow':
specifier: workspace:*
version: link:../workflow
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -183,6 +202,9 @@ importers:
'@uncaged/nerve-workflow-utils':
specifier: workspace:*
version: link:../workflow-utils
'@uncaged/workflow':
specifier: workspace:*
version: link:../workflow
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -215,6 +237,22 @@ importers:
specifier: ^4.1.5
version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))
packages/workflow:
dependencies:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
'@uncaged/nerve-store':
specifier: workspace:*
version: link:../store
devDependencies:
'@rslib/core':
specifier: ^0.21.3
version: 0.21.3(typescript@5.9.3)
'@types/node':
specifier: ^22.0.0
version: 22.19.17
packages/workflow-meta:
dependencies:
'@uncaged/nerve-core':
@@ -229,6 +267,9 @@ importers:
'@uncaged/nerve-workflow-utils':
specifier: workspace:*
version: link:../workflow-utils
'@uncaged/workflow':
specifier: workspace:*
version: link:../workflow
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -254,6 +295,9 @@ importers:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
'@uncaged/workflow':
specifier: workspace:*
version: link:../workflow
zod:
specifier: ^4.3.6
version: 4.3.6