Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19a0ad3f58 | |||
| d81a30f051 | |||
| b683a85376 | |||
| 4a43a7f3dd | |||
| cee65bbd87 | |||
| 591be21bb0 | |||
| 7d0200fa15 | |||
| ebff3d3aca |
@@ -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.
|
||||
+8
-1
@@ -4,9 +4,16 @@
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-store": "workspace:*",
|
||||
"@uncaged/workflow": "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 .",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { parse } from "yaml";
|
||||
|
||||
import type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig } from "@uncaged/workflow";
|
||||
import { type Result, err, isPlainRecord, ok, parseDurationStringToMs } from "./util.js";
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||
|
||||
export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig };
|
||||
|
||||
/** 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;
|
||||
@@ -14,19 +19,6 @@ export type SenseConfig = {
|
||||
on: string[];
|
||||
};
|
||||
|
||||
export type DropOverflowConfig = {
|
||||
concurrency: number;
|
||||
overflow: "drop";
|
||||
};
|
||||
|
||||
export type QueueOverflowConfig = {
|
||||
concurrency: number;
|
||||
overflow: "queue";
|
||||
maxQueue: number;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
|
||||
|
||||
/** Optional HTTP control plane. When `port` is null, the HTTP server is not started. */
|
||||
export type NerveApiConfig = {
|
||||
port: number | null;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/nerve-workflow-utils": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/nerve-workflow-utils": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,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,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,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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,52 @@
|
||||
// @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,
|
||||
} 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";
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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 as string,
|
||||
workflow: obj.workflow as string,
|
||||
prompt: obj.prompt as string,
|
||||
maxRounds: obj.maxRounds as number,
|
||||
dryRun: obj.dryRun as boolean,
|
||||
});
|
||||
}
|
||||
|
||||
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 as string,
|
||||
messages: obj.messages as ResumeThreadMessage["messages"],
|
||||
maxRounds: obj.maxRounds as number,
|
||||
dryRun: obj.dryRun as boolean,
|
||||
});
|
||||
}
|
||||
|
||||
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 string });
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
+10
-19
@@ -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;
|
||||
@@ -56,8 +58,9 @@ export type WorkflowManager = {
|
||||
* Drain active threads for a workflow, then respawn its worker process.
|
||||
* Used for hot reload when bundled workflow output under dist/workflows/<name>/ changes.
|
||||
* Waits up to `drainTimeoutMs` for threads to complete before force-killing.
|
||||
* Pass `null` to use the manager default timeout.
|
||||
*/
|
||||
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number) => Promise<void>;
|
||||
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number | null) => Promise<void>;
|
||||
/**
|
||||
* Schedule a drain+respawn that waits for in-flight runs to finish first.
|
||||
* If no runs are active, drains immediately. Otherwise marks a pending reload
|
||||
@@ -73,7 +76,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 +312,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`,
|
||||
@@ -448,13 +451,16 @@ export function createWorkflowManager(
|
||||
|
||||
async function drainAndRespawn(
|
||||
workflowName: string,
|
||||
drainTimeoutMs: number = DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
drainTimeoutMs: number | null = null,
|
||||
): Promise<void> {
|
||||
if (!trackedWorkflows.has(workflowName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shutdownMs = Math.max(drainTimeoutMs, WORKER_SHUTDOWN_TIMEOUT_MS);
|
||||
const shutdownMs = Math.max(
|
||||
drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
WORKER_SHUTDOWN_TIMEOUT_MS,
|
||||
);
|
||||
hotReloadEvicting.add(workflowName);
|
||||
try {
|
||||
await runtime.evict(workflowName, { shutdownTimeoutMs: shutdownMs });
|
||||
@@ -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");
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
WorkflowChildToParentMessage,
|
||||
} 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: WorkflowChildToParentMessage): 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;
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+48
@@ -4,6 +4,11 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@uncaged/nerve-core': workspace:*
|
||||
'@uncaged/nerve-store': workspace:*
|
||||
'@uncaged/workflow': workspace:*
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -26,6 +31,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 +50,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 +72,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
|
||||
@@ -86,6 +100,9 @@ importers:
|
||||
|
||||
packages/core:
|
||||
dependencies:
|
||||
'@uncaged/workflow':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.8.3
|
||||
@@ -108,6 +125,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 +181,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 +206,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 +241,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 +271,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 +299,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
|
||||
|
||||
Reference in New Issue
Block a user