Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 145a747433 |
+56
-16
@@ -5,33 +5,54 @@
|
||||
## Workspace Lifecycle
|
||||
|
||||
```bash
|
||||
nerve init # scaffold a new workspace (nerve.yaml, senses/, workflows/)
|
||||
nerve init # scaffold workspace (nerve.yaml, senses/, workflows/)
|
||||
nerve init --from <git-url> # clone existing workspace from git
|
||||
nerve init --force # reinitialize (preserves data/)
|
||||
nerve validate # validate nerve.yaml config
|
||||
nerve dev # run kernel foreground (development, Ctrl+C to stop)
|
||||
nerve dev --port 3000 # with HTTP API on specific port
|
||||
nerve start # start daemon (background)
|
||||
nerve start --port 3000 # with HTTP API port
|
||||
nerve stop # stop daemon
|
||||
nerve status # check daemon health (uptime, senses, workflows)
|
||||
nerve daemon # restart daemon (stop + start)
|
||||
nerve daemon restart # stop + start
|
||||
nerve daemon logs # alias for nerve logs
|
||||
```
|
||||
|
||||
## Sense Management
|
||||
|
||||
```bash
|
||||
nerve create sense <name> # scaffold a new sense (compute.ts + schema.ts)
|
||||
nerve sense list # list configured senses
|
||||
nerve create sense <name> --force # overwrite existing
|
||||
nerve sense list # list configured senses and status
|
||||
nerve sense trigger <name> # manually trigger a sense compute
|
||||
nerve sense schema <name> # show sense Drizzle schema
|
||||
nerve sense query <name> # inspect sense SQLite database
|
||||
nerve sense query <name> --sql "SELECT * FROM samples LIMIT 5"
|
||||
nerve sense schema <name> # print CREATE TABLE statements from sense SQLite
|
||||
nerve sense schema <name> --json # as JSON array
|
||||
nerve sense query <name> # inspect sense SQLite database (preview rows)
|
||||
nerve sense query <name> --json # rows as JSON
|
||||
```
|
||||
|
||||
## Workflow Management
|
||||
|
||||
```bash
|
||||
nerve create workflow <name> # scaffold a new workflow
|
||||
nerve create workflow <name> --force # overwrite existing
|
||||
nerve workflow list # list workflow definitions from nerve.yaml
|
||||
nerve workflow status # show live status (concurrency, active, queued)
|
||||
nerve workflow trigger <name> --prompt "..." [--max-rounds N] [--dry-run]
|
||||
nerve workflow list # list configured workflows
|
||||
nerve thread # list active (queued/started) workflow threads
|
||||
```
|
||||
|
||||
## Thread Management
|
||||
|
||||
```bash
|
||||
nerve thread list # list active (queued/started) workflow runs
|
||||
nerve thread list --all # include completed/failed/crashed
|
||||
nerve thread list --workflow <name> # filter by workflow name
|
||||
nerve thread show <runId> # print role rounds for a run (agent-oriented)
|
||||
nerve thread show <runId> --before N # limit rounds (pagination)
|
||||
nerve thread inspect <runId> # show details and thread events
|
||||
nerve thread inspect <runId> --offset N --limit N # paginate events
|
||||
nerve thread kill <runId> # kill a running or queued thread
|
||||
```
|
||||
|
||||
## Knowledge
|
||||
@@ -41,21 +62,30 @@ nerve knowledge sync # chunk files per knowledge.yaml, compute embeddin
|
||||
nerve knowledge query "text" # search indexed knowledge (cosine similarity)
|
||||
nerve knowledge query -g "text" # global search across all indexed repos
|
||||
nerve knowledge query --repo /path "text" # search specific repo
|
||||
nerve knowledge query "text" --limit 20 # max hits (default 10)
|
||||
```
|
||||
|
||||
## Logs & Store
|
||||
|
||||
```bash
|
||||
nerve logs # view daemon logs (last 50 lines)
|
||||
nerve logs -f # follow logs (tail -f style)
|
||||
nerve logs # show daemon log output (last 50 lines)
|
||||
nerve logs -n 200 # last N lines
|
||||
nerve store archive # archive old log entries to JSONL
|
||||
nerve logs --offset 100 # start from line N (pagination)
|
||||
nerve logs -f # follow logs (tail -f style)
|
||||
nerve store archive # archive logs older than 30 days to JSONL
|
||||
nerve store archive --vacuum # also run SQLite VACUUM after archiving
|
||||
```
|
||||
|
||||
## Remote
|
||||
## Remote Management
|
||||
|
||||
```bash
|
||||
nerve remote add <name> <url> # add a remote daemon endpoint
|
||||
nerve remote add <name> <host:port> [--token <token>] # add remote daemon
|
||||
nerve remote list # list all remotes
|
||||
nerve remote show <name> # show remote details
|
||||
nerve remote set-url <name> <host:port> # update remote host
|
||||
nerve remote set-token <name> <token> # update remote token
|
||||
nerve remote remove <name> # remove a remote
|
||||
nerve remote default [<name>] # set or show default remote
|
||||
nerve status --remote <name> # check remote daemon health
|
||||
```
|
||||
|
||||
@@ -67,12 +97,22 @@ my-agent/
|
||||
knowledge.yaml # knowledge index config (optional)
|
||||
senses/
|
||||
cpu-usage/
|
||||
compute.ts # sense implementation
|
||||
schema.ts # Drizzle schema
|
||||
migrations/ # auto-generated
|
||||
src/
|
||||
index.ts # sense compute implementation
|
||||
schema.ts # Drizzle schema (single source of truth)
|
||||
migrations/ # auto-generated by drizzle-kit
|
||||
package.json # with esbuild build script
|
||||
index.js # bundled output (generated by pnpm build)
|
||||
workflows/
|
||||
cleanup/
|
||||
src/index.ts # workflow definition
|
||||
build.ts # factory function
|
||||
moderator.ts # moderator + meta types
|
||||
roles/ # one file per role
|
||||
package.json
|
||||
data/
|
||||
senses/ # per-sense SQLite databases
|
||||
archive/ # archived logs (JSONL)
|
||||
knowledge.db # generated by nerve knowledge sync
|
||||
.knowledge/ # curated knowledge cards
|
||||
```
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# RFC-004: Package Architecture — Shareable Workflows, Roles & Senses
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Status:** Draft
|
||||
**Created:** 2026-04-29
|
||||
|
||||
## Summary
|
||||
|
||||
Make workflows, roles, and senses publishable as lightweight npm packages. Workspaces become pure configuration — selecting packages, wiring adapters, and providing credentials. No builtin workflows in the nerve core.
|
||||
|
||||
## Motivation
|
||||
|
||||
Currently, workflows like `develop-sense` and `develop-workflow` live inside the workspace (`~/.uncaged-nerve/workflows/`). This creates problems:
|
||||
|
||||
1. **No sharing** — every workspace duplicates the same workflow code
|
||||
2. **No versioning** — upgrading a workflow means manual file edits
|
||||
3. **Builtin is a trap** — if we bake workflows into nerve core, they require adapters and LLM providers that may not be installed. A fresh `nerve` install on a bare machine would fail to load builtins.
|
||||
4. **Roles are already shared** — `_shared/workspace-committer.ts` proves the pattern works; we just need to formalize it as packages
|
||||
|
||||
The adapter pattern (`@uncaged/nerve-adapter-hermes`, `@uncaged/nerve-adapter-cursor`) already established the precedent: infrastructure as packages, workspace as wiring.
|
||||
|
||||
## Design
|
||||
|
||||
### Package Taxonomy
|
||||
|
||||
```
|
||||
@uncaged/nerve-core # types, engine
|
||||
@uncaged/nerve-daemon # runtime
|
||||
@uncaged/nerve-workflow-utils # createRole, decorateRole, withDryRun, onFail, etc.
|
||||
|
||||
# Adapters (existing)
|
||||
@uncaged/nerve-adapter-hermes
|
||||
@uncaged/nerve-adapter-cursor
|
||||
|
||||
# Workflows (new)
|
||||
@uncaged/nerve-workflow-solve-issue
|
||||
@uncaged/nerve-workflow-develop-sense
|
||||
@uncaged/nerve-workflow-develop-workflow
|
||||
|
||||
# Shared Roles (new)
|
||||
@uncaged/nerve-role-committer # workspace committer (branch, commit, push)
|
||||
@uncaged/nerve-role-reviewer # code review role
|
||||
@uncaged/nerve-role-publisher # PR creation role
|
||||
|
||||
# Senses (existing pattern, formalized)
|
||||
@uncaged/nerve-sense-cpu-usage
|
||||
@uncaged/nerve-sense-disk-usage
|
||||
```
|
||||
|
||||
### Package Contract
|
||||
|
||||
Each package type exports a factory function:
|
||||
|
||||
#### Workflow Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-workflow-develop-sense
|
||||
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
export type SenseMeta = { /* ... */ };
|
||||
|
||||
export type CreateDevelopSenseDeps = {
|
||||
defaultAdapter: AgentFn;
|
||||
adapters?: Partial<Record<keyof SenseMeta, AgentFn>>;
|
||||
extract: LlmExtractorConfig;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export function createDevelopSenseWorkflow(deps: CreateDevelopSenseDeps): WorkflowDefinition<SenseMeta>;
|
||||
```
|
||||
|
||||
Key design decisions:
|
||||
- `defaultAdapter` + optional `adapters` override per role — via `Partial<Record<keyof Meta, AgentFn>>`
|
||||
- Adapter keys are derived from `Meta` type — adding/removing a role automatically updates the adapter map
|
||||
- Roles that don't need an agent simply don't appear in `adapters` (the `Partial` allows this)
|
||||
|
||||
#### Role Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-role-committer
|
||||
import type { AgentFn, Role } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
export type CommitterMeta = { committed: boolean };
|
||||
|
||||
export function createCommitterRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CommitterMeta> {
|
||||
const inner = createRole(adapter, prompt, committerMetaSchema, extract);
|
||||
return decorateRole(inner, [
|
||||
withDryRun({ label: "committer", meta: { committed: true } }),
|
||||
onFail({ label: "committer", meta: { committed: false } }),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Roles compose with the decorator chain from `workflow-utils`:
|
||||
- `withDryRun` — skip execution in dry-run mode
|
||||
- `onFail` — catch errors into structured failure results
|
||||
- `decorateRole(role, [...])` — apply decorators left-to-right
|
||||
- Custom `RoleDecorator<M>` can be created for project-specific needs
|
||||
|
||||
#### Sense Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-sense-cpu-usage
|
||||
export const senseName = "cpu-usage";
|
||||
export const schema = { /* drizzle schema */ };
|
||||
export async function compute(ctx: SenseContext): Promise<SenseResult>;
|
||||
```
|
||||
|
||||
### Workspace as Configuration
|
||||
|
||||
The workspace becomes a thin wiring layer:
|
||||
|
||||
```
|
||||
~/.uncaged-nerve/
|
||||
nerve.yaml # senses, extract config
|
||||
package.json # depends on workflow/role/adapter packages
|
||||
workflows/
|
||||
develop-sense/
|
||||
index.ts # ~10 lines: import package, wire adapters, export
|
||||
solve-issue/
|
||||
index.ts # same pattern
|
||||
```
|
||||
|
||||
A typical `index.ts`:
|
||||
|
||||
```ts
|
||||
import { createDevelopSenseWorkflow } from "@uncaged/nerve-workflow-develop-sense";
|
||||
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
|
||||
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
|
||||
|
||||
export default createDevelopSenseWorkflow({
|
||||
defaultAdapter: hermesAdapter,
|
||||
adapters: { planner: cursorAdapter, coder: cursorAdapter },
|
||||
extract: { provider: { apiKey, baseUrl, model } },
|
||||
cwd: nerveRoot,
|
||||
});
|
||||
```
|
||||
|
||||
### What Stays in Workspace
|
||||
|
||||
- **Custom workflows** — project-specific workflows that aren't general enough to share
|
||||
- **Custom senses** — project-specific metrics
|
||||
- **Configuration** — adapter selection, credentials, `nerve.yaml`
|
||||
- **Overrides** — a workspace can always write its own role/workflow instead of using a package
|
||||
|
||||
### Dependency Rules
|
||||
|
||||
```
|
||||
nerve-core ← no deps on other nerve packages
|
||||
nerve-workflow-utils ← depends on nerve-core
|
||||
nerve-adapter-* ← depends on nerve-core
|
||||
nerve-role-* ← depends on nerve-core, nerve-workflow-utils
|
||||
nerve-workflow-* ← depends on nerve-core, nerve-workflow-utils, may depend on nerve-role-*
|
||||
nerve-sense-* ← depends on nerve-core
|
||||
nerve-daemon ← depends on nerve-core, nerve-store
|
||||
```
|
||||
|
||||
Workflow packages depend on role packages (not adapters). Adapters are injected at the workspace level.
|
||||
|
||||
### Migration Path
|
||||
|
||||
1. **Phase 1: Extract role packages** — Start with `@uncaged/nerve-role-committer` (already `_shared/workspace-committer.ts`). Publish, update workspace to import from package.
|
||||
2. **Phase 2: Extract workflow packages** — Move `develop-sense` and `develop-workflow` to packages. Workspace `index.ts` becomes pure wiring.
|
||||
3. **Phase 3: Sense packages** — Formalize sense packaging (lower priority, senses are already self-contained directories).
|
||||
4. **Phase 4: Community** — Document the package contract so others can publish workflows/roles/senses.
|
||||
|
||||
### Not in Scope
|
||||
|
||||
- **No builtin workflows** — nerve core ships zero workflows. All workflows are packages installed by the workspace.
|
||||
- **No workflow marketplace/registry** — just npm packages. `pnpm add @uncaged/nerve-workflow-solve-issue`.
|
||||
- **No nerve.yaml workflow declaration** — workflows are still TypeScript entry points. The daemon discovers them the same way it does today.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Monorepo vs separate repos?** — Should workflow/role packages live in the nerve monorepo or separate repos? Monorepo is easier for coordinated releases; separate repos allow independent versioning.
|
||||
2. **Sense package format** — Senses currently bundle with esbuild. Should sense packages ship pre-bundled or as TypeScript source?
|
||||
3. **Version coupling** — How tightly should workflow packages pin `nerve-core`? Peer deps with semver range?
|
||||
|
||||
## Prior Art
|
||||
|
||||
- Adapter packages (`@uncaged/nerve-adapter-*`) — established the factory + injection pattern
|
||||
- `_shared/workspace-committer.ts` — proved roles can be shared across workflows
|
||||
- `createRole` / `decorateRole` / `withDryRun` / `onFail` in `workflow-utils` — building blocks that role packages compose
|
||||
- `defaultAdapter` + `Partial<Record<keyof Meta, AgentFn>>` pattern — adapter injection without coupling
|
||||
@@ -84,22 +84,16 @@ function throwCursorSpawnError(error: SpawnError): never {
|
||||
/** Default adapter config: model auto-selection and 300s wall-clock cap (milliseconds). */
|
||||
const CURSOR_ADAPTER_DEFAULT_MS = 300_000;
|
||||
|
||||
export type CursorAdapterConfig = AgentConfig & {
|
||||
/** When set, passes `--mode=ask` or `--mode=plan` to `cursor-agent` (default runs without extra mode). */
|
||||
mode?: CursorAgentMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Cursor CLI `AgentFn` from adapter config (model, timeout).
|
||||
*/
|
||||
export function createCursorAdapter(config: CursorAdapterConfig): AgentFn {
|
||||
export function createCursorAdapter(config: AgentConfig): AgentFn {
|
||||
const timeoutMs = config.timeout;
|
||||
const mode = config.mode ?? "default";
|
||||
|
||||
return async (prompt: string, context: WorkflowContext): Promise<string> => {
|
||||
const run = await cursorAgent({
|
||||
prompt,
|
||||
mode,
|
||||
mode: "default",
|
||||
model: config.model,
|
||||
cwd: context.workdir,
|
||||
env: null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* RFC-003 Phase 5: nerve validate — workflow adapter usage and extract.
|
||||
* RFC-003 Phase 5: nerve validate — WorkflowSpec adapter usage and extract.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
@@ -38,8 +38,9 @@ describe("validateAgentConfigurationLayer", () => {
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
|
||||
`
|
||||
import type { WorkflowSpec } from "@uncaged/nerve-core";
|
||||
const adapter = async () => "";
|
||||
const spec = {
|
||||
const spec: WorkflowSpec<{ r: { x: number } }> = {
|
||||
name: "demo",
|
||||
roles: {
|
||||
r: { adapter: adapter, prompt: "p", meta: {} as never },
|
||||
|
||||
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
|
||||
/**
|
||||
* Detects `adapter:` usage in workflow TypeScript sources (e.g. createRole wiring).
|
||||
* Detects RoleSpec `adapter:` usage in workflow TypeScript sources.
|
||||
* NOTE: This regex can match occurrences inside comments.
|
||||
*/
|
||||
const WORKFLOW_SPEC_ADAPTER_PATTERN = /adapter:\s*[a-zA-Z_$]/;
|
||||
@@ -26,7 +26,7 @@ function collectTsSourceFiles(dir: string, acc: string[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when any workflow `src` tree appears to use roles with adapters.
|
||||
* Returns true when any workflow `src` tree appears to use WorkflowSpec roles with adapters.
|
||||
*/
|
||||
export function workflowSourcesDeclareAdapterRoles(nerveRoot: string): boolean {
|
||||
const workflowsRoot = join(nerveRoot, "workflows");
|
||||
@@ -66,7 +66,7 @@ export function validateAgentConfigurationLayer(
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"extract: required when workflow roles use adapters (configure extract.provider and extract.model)",
|
||||
"extract: required when WorkflowSpec roles use adapters (configure extract.provider and extract.model)",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "1.0.0-beta.23-c10d10c",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,7 +12,6 @@ export type {
|
||||
ComputeResult,
|
||||
} from "./config.js";
|
||||
export type { Signal, SenseInfo } from "./sense.js";
|
||||
export type { SenseComputeFn, SenseModule } from "./sense-contract.js";
|
||||
export { labelSenseTrigger, senseTriggerLabels } from "./sense-trigger-labels.js";
|
||||
export type {
|
||||
WorkflowMessage,
|
||||
@@ -28,6 +27,7 @@ export type {
|
||||
WorkflowDefinition,
|
||||
} from "./workflow.js";
|
||||
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||
export type { PromptInput, RoleSpec, WorkflowSpec } from "./workflow-spec.js";
|
||||
export { parseDurationStringToMs } from "./duration.js";
|
||||
export type { Schema, ExtractFn } from "./extract-layer.js";
|
||||
export { ExtractError } from "./extract-layer.js";
|
||||
|
||||
@@ -330,7 +330,7 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
|
||||
if (Object.hasOwn(obj, "agents")) {
|
||||
return err(
|
||||
new Error(
|
||||
"agents: key is no longer supported — declare adapters on workflow roles (RFC-003)",
|
||||
"agents: key is no longer supported — declare adapters on WorkflowSpec roles (RFC-003)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
/**
|
||||
* The function signature every sense `src/index.ts` must export as a named
|
||||
* `compute` export.
|
||||
*
|
||||
* Pure: receives only an AbortSignal. No DB, no peers.
|
||||
* Return `null` to stay silent, or a value `T` to emit a Signal.
|
||||
* The runtime handles persistence via `db.insert(table).values(result)`.
|
||||
*/
|
||||
export type SenseComputeFn<T = unknown> = (signal: AbortSignal) => Promise<T | null>;
|
||||
|
||||
/**
|
||||
* The full shape a sense module (`src/index.ts`) must export.
|
||||
* `compute` provides the data; `table` tells the runtime where to persist it.
|
||||
*/
|
||||
export type SenseModule<T = unknown> = {
|
||||
compute: SenseComputeFn<T>;
|
||||
table: SQLiteTable;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Schema } from "./extract-layer.js";
|
||||
import type { AgentFn, Moderator, RoleMeta, StartStep, WorkflowMessage } from "./workflow.js";
|
||||
|
||||
/** Static string or async prompt built from thread context (RFC-003 dynamic prompts). */
|
||||
export type PromptInput =
|
||||
| string
|
||||
| ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
|
||||
|
||||
/**
|
||||
* Authoring-time role: adapter function, prompt, extract schema (RFC-003).
|
||||
* Compiles to runtime `Role<Meta>` via `compileWorkflowSpec`.
|
||||
*/
|
||||
export type RoleSpec<Meta extends Record<string, unknown>> = {
|
||||
adapter: AgentFn;
|
||||
prompt: PromptInput;
|
||||
meta: Schema<Meta>;
|
||||
};
|
||||
|
||||
/** User-facing workflow authoring shape; compiles to `WorkflowDefinition`. */
|
||||
export type WorkflowSpec<M extends RoleMeta> = {
|
||||
name: string;
|
||||
roles: { [K in keyof M]: RoleSpec<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
AgentFn,
|
||||
ModeratorContext,
|
||||
RoleMeta,
|
||||
Schema,
|
||||
StartStep,
|
||||
WorkflowContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
WorkflowSpec,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END, START } from "@uncaged/nerve-core";
|
||||
|
||||
import { compileWorkflowSpec } from "../compile-workflow-spec.js";
|
||||
|
||||
type DemoMeta = { n: number };
|
||||
|
||||
function echoAdapter(): AgentFn {
|
||||
return async (prompt: string, _ctx: WorkflowContext) => prompt;
|
||||
}
|
||||
|
||||
function makeStart(threadId = "t1"): StartStep {
|
||||
return {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: { maxRounds: 10, dryRun: false, threadId },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(start: StartStep, messages: WorkflowMessage[]): WorkflowContext {
|
||||
return {
|
||||
start,
|
||||
messages,
|
||||
workdir: "/tmp/repo",
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compileWorkflowSpec", () => {
|
||||
it("compiles WorkflowSpec to WorkflowDefinition shape", () => {
|
||||
const witness: DemoMeta | null = null;
|
||||
const schema: Schema<DemoMeta> = { witness };
|
||||
|
||||
const spec: WorkflowSpec<{ main: DemoMeta }> = {
|
||||
name: "demo",
|
||||
roles: {
|
||||
main: {
|
||||
adapter: echoAdapter(),
|
||||
prompt: "hello",
|
||||
meta: schema,
|
||||
},
|
||||
},
|
||||
moderator: (_ctx: ModeratorContext<{ main: DemoMeta }>) => END,
|
||||
};
|
||||
|
||||
const def = compileWorkflowSpec(spec, {
|
||||
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
|
||||
createContext: makeContext,
|
||||
});
|
||||
|
||||
expect(def.name).toBe("demo");
|
||||
expect(typeof def.roles.main).toBe("function");
|
||||
expect(def.moderator).toBe(spec.moderator);
|
||||
});
|
||||
|
||||
it("runs AgentFn then ExtractFn in order", async () => {
|
||||
const witness: DemoMeta | null = null;
|
||||
const schema: Schema<DemoMeta> = { witness };
|
||||
|
||||
const order: string[] = [];
|
||||
|
||||
const baseEcho = echoAdapter();
|
||||
const spyAgent: AgentFn = async (prompt, ctx) => {
|
||||
order.push("agent");
|
||||
return baseEcho(prompt, ctx);
|
||||
};
|
||||
|
||||
const spec: WorkflowSpec<{ main: DemoMeta }> = {
|
||||
name: "order-test",
|
||||
roles: {
|
||||
main: {
|
||||
adapter: spyAgent,
|
||||
prompt: "ping",
|
||||
meta: schema,
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
};
|
||||
|
||||
const def = compileWorkflowSpec(spec, {
|
||||
extractFn: async <T>(raw: string, _sch: Schema<T>) => {
|
||||
order.push("extract");
|
||||
return { n: raw.length } as T;
|
||||
},
|
||||
createContext: makeContext,
|
||||
});
|
||||
|
||||
const start = makeStart();
|
||||
await def.roles.main(start, []);
|
||||
|
||||
expect(order).toEqual(["agent", "extract"]);
|
||||
});
|
||||
|
||||
it("passes WorkflowContext from createContext to AgentFn (adapter owns timeout)", async () => {
|
||||
const witness: DemoMeta | null = null;
|
||||
const schema: Schema<DemoMeta> = { witness };
|
||||
|
||||
const seenCtx: WorkflowContext[] = [];
|
||||
|
||||
const adapter: AgentFn = async (_prompt, ctx) => {
|
||||
seenCtx.push(ctx);
|
||||
return "x";
|
||||
};
|
||||
|
||||
const spec: WorkflowSpec<{ main: DemoMeta }> = {
|
||||
name: "ctx",
|
||||
roles: {
|
||||
main: {
|
||||
adapter,
|
||||
prompt: "x",
|
||||
meta: schema,
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
};
|
||||
|
||||
await compileWorkflowSpec(spec, {
|
||||
extractFn: async <T>(_raw: string, _s: Schema<T>) => ({ n: 0 }) as T,
|
||||
createContext: makeContext,
|
||||
}).roles.main(makeStart(), []);
|
||||
|
||||
expect(seenCtx).toHaveLength(1);
|
||||
expect(seenCtx[0].workdir).toBe("/tmp/repo");
|
||||
});
|
||||
|
||||
it("resolves dynamic prompt functions before AgentFn", async () => {
|
||||
const witness: DemoMeta | null = null;
|
||||
const schema: Schema<DemoMeta> = { witness };
|
||||
|
||||
const spec: WorkflowSpec<{ main: DemoMeta }> = {
|
||||
name: "dyn",
|
||||
roles: {
|
||||
main: {
|
||||
adapter: echoAdapter(),
|
||||
prompt: async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`,
|
||||
meta: schema,
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
};
|
||||
|
||||
const def = compileWorkflowSpec(spec, {
|
||||
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
|
||||
createContext: makeContext,
|
||||
});
|
||||
|
||||
const start = makeStart("thread-x");
|
||||
const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }];
|
||||
const out = await def.roles.main(start, msgs);
|
||||
expect(out.content).toBe("tid=thread-x n=1");
|
||||
expect(out.meta.n).toBe(out.content.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backward compatibility", () => {
|
||||
it("hand-written Role-based WorkflowDefinition remains valid", async () => {
|
||||
type M = RoleMeta & { legacy: { id: string } };
|
||||
|
||||
const manual: WorkflowDefinition<M> = {
|
||||
name: "legacy",
|
||||
roles: {
|
||||
legacy: async (_start, _messages) => ({
|
||||
content: "hi",
|
||||
meta: { id: "a" },
|
||||
}),
|
||||
},
|
||||
moderator: (_ctx: ModeratorContext<M>) => END,
|
||||
};
|
||||
|
||||
const start = makeStart();
|
||||
const out = await manual.roles.legacy(start, []);
|
||||
expect(out.content).toBe("hi");
|
||||
expect(out.meta.id).toBe("a");
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,10 @@ import { drizzle } from "drizzle-orm/node-sqlite";
|
||||
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createBlobStore } from "@uncaged/nerve-store";
|
||||
import { parseParentMessage } from "../ipc.js";
|
||||
import { executeCompute, openSenseDb, runMigrations } from "../sense-runtime.js";
|
||||
import type { DrizzleDB, SenseRuntime } from "../sense-runtime.js";
|
||||
import { executeCompute, openPeerDb, openSenseDb, runMigrations } from "../sense-runtime.js";
|
||||
import type { ComputeFn, DrizzleDB, PeerMap, SenseRuntime } from "../sense-runtime.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -150,50 +151,76 @@ describe("openSenseDb", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// openPeerDb
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("openPeerDb", () => {
|
||||
it("opens an existing db in read-only mode", () => {
|
||||
// Create a writable db first
|
||||
const dbPath = makeTempDbPath();
|
||||
const sqlite = new DatabaseSync(dbPath);
|
||||
sqlite.exec(INIT_SQL);
|
||||
sqlite.prepare("INSERT INTO samples (ts, value) VALUES (1, 42.0)").run();
|
||||
sqlite.close();
|
||||
|
||||
const result = openPeerDb(dbPath);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
// Should be able to read
|
||||
const peerDb = result.value;
|
||||
const rows = peerDb.select().from(samples).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].value).toBe(42.0);
|
||||
});
|
||||
|
||||
it("returns err when db path does not exist", () => {
|
||||
const result = openPeerDb("/nonexistent/path/to/peer.db");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// executeCompute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("executeCompute", () => {
|
||||
function makeRuntime(
|
||||
computeFn: SenseRuntime["compute"],
|
||||
sqlite?: DatabaseSync,
|
||||
): { runtime: SenseRuntime; sqlite: DatabaseSync } {
|
||||
const db_sqlite = sqlite ?? new DatabaseSync(":memory:");
|
||||
if (!sqlite) db_sqlite.exec(INIT_SQL);
|
||||
const db = drizzle({ client: db_sqlite }) as DrizzleDB;
|
||||
function makeRuntime(computeFn: ComputeFn): {
|
||||
runtime: SenseRuntime;
|
||||
sqlite: DatabaseSync;
|
||||
} {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
sqlite.exec(INIT_SQL);
|
||||
const db = drizzle({ client: sqlite }) as DrizzleDB;
|
||||
return {
|
||||
runtime: {
|
||||
name: "test-sense",
|
||||
db,
|
||||
compute: computeFn,
|
||||
table: samples,
|
||||
persistSignal: () => {},
|
||||
},
|
||||
sqlite: db_sqlite,
|
||||
runtime: { name: "test-sense", db, compute: computeFn, persistSignal: () => {} },
|
||||
sqlite,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns non-null and inserts into table when compute returns data", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(async () => ({
|
||||
ts: 1000,
|
||||
value: 0.5,
|
||||
}));
|
||||
const emptyPeers: PeerMap = {};
|
||||
|
||||
const result = await executeCompute(runtime);
|
||||
it("returns the compute result when compute returns a non-null value", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(async (db) => {
|
||||
await db.insert(samples).values({ ts: Date.now(), value: 0.5 });
|
||||
return { signal: 0.5, workflow: null };
|
||||
});
|
||||
|
||||
const result = await executeCompute(runtime, emptyPeers);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toEqual({ ts: 1000, value: 0.5 });
|
||||
expect(result.value).toEqual({ signal: 0.5, workflow: null });
|
||||
|
||||
const rows = sqlite.prepare("SELECT * FROM samples").all();
|
||||
expect(rows).toHaveLength(1);
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("returns null and does not insert when compute returns null", async () => {
|
||||
it("returns null (no signal) when compute returns null", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(async () => null);
|
||||
|
||||
const result = await executeCompute(runtime);
|
||||
const result = await executeCompute(runtime, emptyPeers);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeNull();
|
||||
@@ -208,14 +235,60 @@ describe("executeCompute", () => {
|
||||
throw new Error("something went wrong");
|
||||
});
|
||||
|
||||
const result = await executeCompute(runtime);
|
||||
const result = await executeCompute(runtime, emptyPeers);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toContain("something went wrong");
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("inserts correctly into the sense db from openSenseDb", async () => {
|
||||
it("compute can read from peers", async () => {
|
||||
// Set up a peer db with data
|
||||
const peerSqlite = new DatabaseSync(":memory:");
|
||||
peerSqlite.exec(INIT_SQL);
|
||||
peerSqlite.prepare("INSERT INTO samples (ts, value) VALUES (100, 3.14)").run();
|
||||
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
|
||||
|
||||
const peers: PeerMap = { "other-sense": peerDb };
|
||||
|
||||
const { runtime, sqlite } = makeRuntime(async (_db, p) => {
|
||||
const rows = await p["other-sense"].select().from(samples).all();
|
||||
return rows.length > 0 ? { signal: rows[0].value, workflow: null } : null;
|
||||
});
|
||||
|
||||
const result = await executeCompute(runtime, peers);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toEqual({ signal: 3.14, workflow: null });
|
||||
|
||||
peerSqlite.close();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("write to own db does not affect peer db (isolation)", async () => {
|
||||
const peerSqlite = new DatabaseSync(":memory:");
|
||||
peerSqlite.exec(INIT_SQL);
|
||||
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
|
||||
const peers: PeerMap = { "peer-sense": peerDb };
|
||||
|
||||
const { runtime, sqlite } = makeRuntime(async (db) => {
|
||||
await db.insert(samples).values({ ts: 999, value: 9.9 });
|
||||
return { signal: 9.9, workflow: null };
|
||||
});
|
||||
|
||||
await executeCompute(runtime, peers);
|
||||
|
||||
const peerRows = peerSqlite.prepare("SELECT * FROM samples").all();
|
||||
expect(peerRows).toHaveLength(0);
|
||||
|
||||
const ownRows = sqlite.prepare("SELECT * FROM samples").all();
|
||||
expect(ownRows).toHaveLength(1);
|
||||
|
||||
peerSqlite.close();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("inserts correctly into the sense db directory path", async () => {
|
||||
const dbPath = makeTempDbPath();
|
||||
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
|
||||
const dbResult = openSenseDb(dbPath, migrationsDir);
|
||||
@@ -228,12 +301,14 @@ describe("executeCompute", () => {
|
||||
const runtime: SenseRuntime = {
|
||||
name: "cpu-usage",
|
||||
db,
|
||||
compute: async () => ({ ts: 1000, value: 1.23 }),
|
||||
table: samples,
|
||||
compute: async (d) => {
|
||||
await d.insert(samples).values({ ts: 1000, value: 1.23 });
|
||||
return { signal: 1.23, workflow: null };
|
||||
},
|
||||
persistSignal: () => {},
|
||||
};
|
||||
|
||||
const result = await executeCompute(runtime);
|
||||
const result = await executeCompute(runtime, {});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const rows = dbSqlite.prepare("SELECT * FROM samples").all() as Array<{
|
||||
@@ -248,17 +323,17 @@ describe("executeCompute", () => {
|
||||
|
||||
it("returns err when compute exceeds timeoutMs", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(
|
||||
(signal) =>
|
||||
(_db, _peers, options) =>
|
||||
new Promise<null>((resolve, reject) => {
|
||||
const t = setTimeout(() => resolve(null), 5_000);
|
||||
signal.addEventListener("abort", () => {
|
||||
options?.signal.addEventListener("abort", () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executeCompute(runtime, 50);
|
||||
const result = await executeCompute(runtime, emptyPeers, 50);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toMatch(/timed out/i);
|
||||
@@ -266,25 +341,39 @@ describe("executeCompute", () => {
|
||||
});
|
||||
|
||||
it("completes within timeout when compute is fast", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(async () => ({ ts: 1, value: 42 }));
|
||||
const result = await executeCompute(runtime, 5_000);
|
||||
const { runtime, sqlite } = makeRuntime(async () => ({ signal: 42, workflow: null }));
|
||||
const result = await executeCompute(runtime, emptyPeers, 5_000);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toEqual({ ts: 1, value: 42 });
|
||||
expect(result.value).toEqual({ signal: 42, workflow: null });
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("passes AbortSignal to compute fn", async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
const { runtime, sqlite } = makeRuntime(async (signal) => {
|
||||
capturedSignal = signal;
|
||||
const { runtime, sqlite } = makeRuntime(async (_db, _peers, options) => {
|
||||
capturedSignal = options?.signal;
|
||||
return null;
|
||||
});
|
||||
|
||||
await executeCompute(runtime, 1_000);
|
||||
await executeCompute(runtime, emptyPeers, 1_000);
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("passes BlobStore as options.blobs when blobStore argument is provided", async () => {
|
||||
const blobsRoot = mkdtempSync(join(tmpdir(), "nerve-blobs-"));
|
||||
const blobStore = createBlobStore(blobsRoot);
|
||||
let seen: ReturnType<typeof createBlobStore> | undefined;
|
||||
const { runtime, sqlite } = makeRuntime(async (_db, _peers, options) => {
|
||||
seen = options?.blobs;
|
||||
return null;
|
||||
});
|
||||
|
||||
await executeCompute(runtime, emptyPeers, undefined, blobStore);
|
||||
expect(seen).toBe(blobStore);
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -340,14 +429,19 @@ describe("runMigrations journal", () => {
|
||||
const first = runMigrations(sqlite, dir);
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
// Insert a row so we can verify second run doesn't fail on CREATE TABLE
|
||||
sqlite.exec("INSERT INTO samples (ts, value) VALUES (1, 1.0)");
|
||||
|
||||
// Run again — migration must NOT re-run (would fail without IF NOT EXISTS but
|
||||
// the journal prevents it even for non-idempotent SQL)
|
||||
const nonIdempotentSql = "CREATE TABLE samples2 (id INTEGER PRIMARY KEY)";
|
||||
writeFileSync(join(dir, "0002_samples2.sql"), nonIdempotentSql);
|
||||
|
||||
// First time: creates samples2
|
||||
const second = runMigrations(sqlite, dir);
|
||||
expect(second.ok).toBe(true);
|
||||
|
||||
// Second time: 0002 already in journal, must not re-run
|
||||
const third = runMigrations(sqlite, dir);
|
||||
expect(third.ok).toBe(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
Role,
|
||||
RoleMeta,
|
||||
RoleSpec,
|
||||
Schema,
|
||||
StartStep,
|
||||
WorkflowContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
WorkflowSpec,
|
||||
} from "@uncaged/nerve-core";
|
||||
|
||||
export type CompileWorkflowSpecDeps = {
|
||||
/**
|
||||
* Typed extraction for agent raw output (global/role merge applied before compile).
|
||||
*/
|
||||
extractFn: <T>(raw: string, schema: Schema<T>) => Promise<T>;
|
||||
/** Builds thread context for each role invocation (workdir, cancellation, etc.). */
|
||||
createContext: (start: StartStep, messages: WorkflowMessage[]) => WorkflowContext;
|
||||
};
|
||||
|
||||
function compileRoleForSpec<Meta extends Record<string, unknown>>(
|
||||
roleSpec: RoleSpec<Meta>,
|
||||
deps: CompileWorkflowSpecDeps,
|
||||
): Role<Meta> {
|
||||
return async (start: StartStep, messages: WorkflowMessage[]) => {
|
||||
const ctx = deps.createContext(start, messages);
|
||||
|
||||
const promptText =
|
||||
typeof roleSpec.prompt === "string"
|
||||
? roleSpec.prompt
|
||||
: await roleSpec.prompt(start, messages);
|
||||
|
||||
const raw = await roleSpec.adapter(promptText, ctx);
|
||||
const meta = await deps.extractFn(raw, roleSpec.meta);
|
||||
return { content: raw, meta };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns RFC-003 `WorkflowSpec` into engine `WorkflowDefinition`: wires adapters and extract per role.
|
||||
*/
|
||||
export function compileWorkflowSpec<M extends RoleMeta>(
|
||||
spec: WorkflowSpec<M>,
|
||||
deps: CompileWorkflowSpecDeps,
|
||||
): WorkflowDefinition<M> {
|
||||
const roleKeys = Object.keys(spec.roles) as Array<keyof M & string>;
|
||||
const roles = {} as WorkflowDefinition<M>["roles"];
|
||||
|
||||
for (const key of roleKeys) {
|
||||
roles[key] = compileRoleForSpec(spec.roles[key], deps);
|
||||
}
|
||||
|
||||
return {
|
||||
name: spec.name,
|
||||
roles,
|
||||
moderator: spec.moderator,
|
||||
};
|
||||
}
|
||||
@@ -58,4 +58,6 @@ export type {
|
||||
export { createWorkflowManager } from "./workflow-manager.js";
|
||||
export type { WorkflowManager } from "./workflow-manager.js";
|
||||
|
||||
export { compileWorkflowSpec } from "./compile-workflow-spec.js";
|
||||
export type { CompileWorkflowSpecDeps } from "./compile-workflow-spec.js";
|
||||
export { createEchoAgent } from "./agent-adapters/echo.js";
|
||||
|
||||
@@ -4,20 +4,43 @@ import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { drizzle } from "drizzle-orm/node-sqlite";
|
||||
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import type { Result, SenseComputeFn } from "@uncaged/nerve-core";
|
||||
import type { ComputeResult, Result } from "@uncaged/nerve-core";
|
||||
import { DEFAULT_SENSE_SIGNAL_RETENTION, err, isPlainRecord, ok } from "@uncaged/nerve-core";
|
||||
|
||||
import type { BlobStore } from "@uncaged/nerve-store";
|
||||
|
||||
/** A Drizzle DB instance (schema-generic) */
|
||||
export type DrizzleDB = NodeSQLiteDatabase<Record<string, never>>;
|
||||
|
||||
/** Read-only map of peer sense name → their Drizzle DB */
|
||||
export type PeerMap = Readonly<Record<string, DrizzleDB>>;
|
||||
|
||||
/** Options passed to a compute function */
|
||||
export type ComputeOptions = {
|
||||
signal: AbortSignal;
|
||||
/** CAS under `data/blobs/`; injected by the sense worker when available. */
|
||||
blobs?: BlobStore;
|
||||
};
|
||||
|
||||
/**
|
||||
* The shape every sense's index.ts must export.
|
||||
* Engine injects `db` (read-write), `peers` (read-only), and `options`
|
||||
* (`signal`, and `blobs` when running in the sense worker — RFC-001 §8 CAS).
|
||||
* Returns a structured result when a signal should be emitted (and optionally a workflow),
|
||||
* or null for silence.
|
||||
*/
|
||||
export type ComputeFn<T = unknown> = (
|
||||
db: DrizzleDB,
|
||||
peers: PeerMap,
|
||||
options?: ComputeOptions,
|
||||
) => Promise<ComputeResult<T>>;
|
||||
|
||||
/** All state held for one sense inside a worker */
|
||||
export type SenseRuntime = {
|
||||
name: string;
|
||||
db: DrizzleDB;
|
||||
compute: SenseComputeFn;
|
||||
table: SQLiteTable;
|
||||
compute: ComputeFn;
|
||||
persistSignal: (payload: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -103,13 +126,13 @@ export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Resu
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
|
||||
const SIGNAL_INSERTS_PER_PRUNE = 100;
|
||||
|
||||
/**
|
||||
* Open (or create) the SQLite file at `dbPath`, run all migrations in
|
||||
* `migrationsDir`, and wrap with Drizzle ORM.
|
||||
*/
|
||||
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
|
||||
const SIGNAL_INSERTS_PER_PRUNE = 100;
|
||||
|
||||
export function openSenseDb(
|
||||
dbPath: string,
|
||||
migrationsDir: string,
|
||||
@@ -161,12 +184,27 @@ export function openSenseDb(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically import the compute function and table from a sense's index.ts/js.
|
||||
* The module must export a named `compute` function and a named `table` (SQLiteTable).
|
||||
* Open a peer sense DB in read-only mode (no migrations).
|
||||
*/
|
||||
export async function loadSenseModule(
|
||||
senseIndexPath: string,
|
||||
): Promise<Result<{ compute: SenseComputeFn; table: SQLiteTable }>> {
|
||||
export function openPeerDb(dbPath: string): Result<DrizzleDB> {
|
||||
let sqlite: DatabaseSync;
|
||||
|
||||
try {
|
||||
sqlite = new DatabaseSync(dbPath, { readOnly: true });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return err(new Error(`Failed to open peer database "${dbPath}" (readonly): ${msg}`));
|
||||
}
|
||||
|
||||
// Same schema-agnostic Drizzle wrapper as openSenseDb.
|
||||
return ok(drizzle({ client: sqlite }) as DrizzleDB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically import the compute function from a sense's index.ts/js.
|
||||
* The module must export a named `compute` function.
|
||||
*/
|
||||
export async function loadComputeFn(senseIndexPath: string): Promise<Result<ComputeFn>> {
|
||||
let mod: unknown;
|
||||
|
||||
try {
|
||||
@@ -183,32 +221,26 @@ export async function loadSenseModule(
|
||||
);
|
||||
}
|
||||
|
||||
if (!("table" in mod) || mod.table === null || typeof mod.table !== "object") {
|
||||
return err(
|
||||
new Error(
|
||||
`Sense module "${senseIndexPath}" must export a named "table" (drizzle SQLiteTable)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ok({
|
||||
compute: mod.compute as SenseComputeFn,
|
||||
table: mod.table as SQLiteTable,
|
||||
});
|
||||
return ok(mod.compute as ComputeFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a sense's compute function with an optional soft timeout.
|
||||
* If timeoutMs is provided and compute takes longer, the AbortSignal is
|
||||
* triggered and an error Result is returned.
|
||||
* When compute returns non-null, the result is persisted to the sense's table
|
||||
* via `db.insert(table).values(result)`.
|
||||
* When `blobStore` is set, it is exposed as `options.blobs` (see RFC-001 §8).
|
||||
*/
|
||||
export async function executeCompute(
|
||||
runtime: SenseRuntime,
|
||||
peers: PeerMap,
|
||||
timeoutMs?: number,
|
||||
): Promise<Result<unknown | null>> {
|
||||
blobStore?: BlobStore,
|
||||
): Promise<Result<ComputeResult<unknown>>> {
|
||||
const controller = new AbortController();
|
||||
const options: ComputeOptions =
|
||||
blobStore !== undefined
|
||||
? { signal: controller.signal, blobs: blobStore }
|
||||
: { signal: controller.signal };
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise =
|
||||
@@ -222,16 +254,10 @@ export async function executeCompute(
|
||||
: null;
|
||||
|
||||
try {
|
||||
const computePromise = runtime.compute(controller.signal);
|
||||
const computePromise = runtime.compute(runtime.db, peers, options);
|
||||
const result = timeoutPromise
|
||||
? await Promise.race([computePromise, timeoutPromise])
|
||||
: await computePromise;
|
||||
|
||||
if (result !== null) {
|
||||
// Cast required: DrizzleDB is schema-agnostic; the sense module guarantees shape compatibility.
|
||||
await runtime.db.insert(runtime.table).values(result as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return ok(result);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
*
|
||||
* Entry point for `nerve worker sense --group <name>`.
|
||||
* Receives the group name via CLI args, reads nerve.yaml, initialises one
|
||||
* SenseRuntime per sense in the group, then signals ready and enters the
|
||||
* IPC event loop.
|
||||
* SenseRuntime per sense in the group, builds peer read-only connections,
|
||||
* then signals ready and enters the IPC event loop.
|
||||
*
|
||||
* Layout assumptions (nerve user config at `~/.uncaged-nerve/`):
|
||||
* senses/<name>/index.js ← compiled compute
|
||||
* senses/<name>/migrations/ ← SQL migration files
|
||||
* data/senses/<name>.db ← SQLite data file
|
||||
* data/blobs/<aa>/<hashrest> ← CAS (sha256), via options.blobs in compute
|
||||
* nerve.yaml ← config
|
||||
*/
|
||||
|
||||
@@ -21,10 +22,11 @@ import { join, resolve } from "node:path";
|
||||
import { parseNerveConfig, routeSenseComputeOutput } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
|
||||
import { createBlobStore } from "@uncaged/nerve-store";
|
||||
import type { WorkerToParentMessage } from "./ipc.js";
|
||||
import { parseParentMessage } from "./ipc.js";
|
||||
import { executeCompute, loadSenseModule, openSenseDb } from "./sense-runtime.js";
|
||||
import type { SenseRuntime } from "./sense-runtime.js";
|
||||
import { executeCompute, loadComputeFn, openPeerDb, openSenseDb } from "./sense-runtime.js";
|
||||
import type { DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
|
||||
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -50,7 +52,7 @@ function sendError(sense: string, error: string): void {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialisation helpers
|
||||
// Initialisation helpers (each extracted to keep bootstrap complexity low)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readConfig(nerveRoot: string): NerveConfig {
|
||||
@@ -76,7 +78,7 @@ async function initSense(
|
||||
nerveRoot: string,
|
||||
senseName: string,
|
||||
retention: number,
|
||||
): Promise<SenseRuntime> {
|
||||
): Promise<{ db: DrizzleDB; runtime: SenseRuntime }> {
|
||||
const dbPath = join(nerveRoot, "data", "senses", `${senseName}.db`);
|
||||
const migrationsDir = join(nerveRoot, "senses", senseName, "migrations");
|
||||
const senseIndexPath = resolve(join(nerveRoot, "senses", senseName, "index.js"));
|
||||
@@ -86,20 +88,55 @@ async function initSense(
|
||||
throw new Error(`Failed to init DB for "${senseName}": ${dbResult.error.message}`);
|
||||
}
|
||||
|
||||
const moduleResult = await loadSenseModule(senseIndexPath);
|
||||
if (!moduleResult.ok) {
|
||||
throw new Error(`Failed to load module for "${senseName}": ${moduleResult.error.message}`);
|
||||
const computeResult = await loadComputeFn(senseIndexPath);
|
||||
if (!computeResult.ok) {
|
||||
throw new Error(`Failed to load compute for "${senseName}": ${computeResult.error.message}`);
|
||||
}
|
||||
|
||||
const { db } = dbResult.value;
|
||||
return {
|
||||
name: senseName,
|
||||
db: dbResult.value.db,
|
||||
compute: moduleResult.value.compute,
|
||||
table: moduleResult.value.table,
|
||||
persistSignal: dbResult.value.persistSignal,
|
||||
db,
|
||||
runtime: {
|
||||
name: senseName,
|
||||
db,
|
||||
compute: computeResult.value,
|
||||
persistSignal: dbResult.value.persistSignal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPeers(
|
||||
nerveRoot: string,
|
||||
allSenseNames: string[],
|
||||
ownDbs: Map<string, DrizzleDB>,
|
||||
groupSenseNames: Set<string>,
|
||||
): PeerMap {
|
||||
const entries: [string, DrizzleDB][] = [];
|
||||
|
||||
for (const peerName of allSenseNames) {
|
||||
// Exclude senses that belong to this worker's own group — they are not peers
|
||||
if (groupSenseNames.has(peerName)) continue;
|
||||
|
||||
const own = ownDbs.get(peerName);
|
||||
if (own !== undefined) {
|
||||
entries.push([peerName, own]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const peerDbPath = join(nerveRoot, "data", "senses", `${peerName}.db`);
|
||||
const peerResult = openPeerDb(peerDbPath);
|
||||
if (!peerResult.ok) {
|
||||
process.stderr.write(
|
||||
`[sense-worker] Warning: could not open peer DB for "${peerName}": ${peerResult.error.message}\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
entries.push([peerName, peerResult.value]);
|
||||
}
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grace period: hard kill after soft timeout
|
||||
//
|
||||
@@ -136,11 +173,13 @@ function clearGracePeriodTimer(senseName: string): void {
|
||||
async function runCompute(
|
||||
senseName: string,
|
||||
runtime: SenseRuntime,
|
||||
peers: PeerMap,
|
||||
timeoutMs: number,
|
||||
gracePeriodMs: number | null,
|
||||
blobStore: ReturnType<typeof createBlobStore>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await executeCompute(runtime, timeoutMs);
|
||||
const result = await executeCompute(runtime, peers, timeoutMs, blobStore);
|
||||
if (!result.ok) {
|
||||
sendError(senseName, result.error.message);
|
||||
if (gracePeriodMs !== null && result.error.message.includes("timed out")) {
|
||||
@@ -171,9 +210,11 @@ async function runCompute(
|
||||
function handleMessage(
|
||||
raw: unknown,
|
||||
runtimes: Map<string, SenseRuntime>,
|
||||
peers: PeerMap,
|
||||
group: string,
|
||||
senseConfigs: Map<string, { timeout: number | null; gracePeriod: number | null }>,
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
blobStore: ReturnType<typeof createBlobStore>,
|
||||
): void {
|
||||
const parseResult = parseParentMessage(raw);
|
||||
if (!parseResult.ok) {
|
||||
@@ -204,13 +245,14 @@ function handleMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up timeout/gracePeriod per-sense at compute time (RFC §5.3: these are per-sense)
|
||||
const sc = senseConfigs.get(msg.sense);
|
||||
const timeoutMs = sc?.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
const gracePeriodMs = sc?.gracePeriod ?? null;
|
||||
|
||||
const previous = inFlight.get(msg.sense) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs))
|
||||
.then(() => runCompute(msg.sense, runtime, peers, timeoutMs, gracePeriodMs, blobStore))
|
||||
.catch((e: unknown) => {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
sendError(msg.sense, errMsg);
|
||||
@@ -237,12 +279,14 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
|
||||
}
|
||||
|
||||
const runtimes = new Map<string, SenseRuntime>();
|
||||
const ownDbs = new Map<string, DrizzleDB>();
|
||||
const failedSenses: string[] = [];
|
||||
|
||||
for (const senseName of groupSenses) {
|
||||
try {
|
||||
const retention = config.senses[senseName].retention;
|
||||
const runtime = await initSense(nerveRoot, senseName, retention);
|
||||
const { db, runtime } = await initSense(nerveRoot, senseName, retention);
|
||||
ownDbs.set(senseName, db);
|
||||
runtimes.set(senseName, runtime);
|
||||
} catch (e: unknown) {
|
||||
const eMsg = e instanceof Error ? e.message : String(e);
|
||||
@@ -253,11 +297,16 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// If ALL senses failed, exit with error so kernel respawns
|
||||
if (runtimes.size === 0) {
|
||||
process.stderr.write(`[sense-worker] All senses in group "${group}" failed to load, exiting\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const groupSenseNames = new Set(groupSenses);
|
||||
const peers = buildPeers(nerveRoot, Object.keys(config.senses), ownDbs, groupSenseNames);
|
||||
|
||||
// Build per-sense timeout/gracePeriod map (RFC §5.3: these are per-sense, not per-group)
|
||||
const senseConfigs = new Map<string, { timeout: number | null; gracePeriod: number | null }>();
|
||||
for (const senseName of groupSenses) {
|
||||
const sc = config.senses[senseName];
|
||||
@@ -268,11 +317,12 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
|
||||
}
|
||||
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
const blobStore = createBlobStore(join(nerveRoot, "data", "blobs"));
|
||||
|
||||
sendReady();
|
||||
|
||||
process.on("message", (raw: unknown) => {
|
||||
handleMessage(raw, runtimes, group, senseConfigs, inFlight);
|
||||
handleMessage(raw, runtimes, peers, group, senseConfigs, inFlight, blobStore);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/nerve-role-committer",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-workflow-utils": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
export default defineConfig({
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
dts: true,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "node",
|
||||
cleanDistPath: true,
|
||||
},
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole, decorateRole, onFail, withDryRun } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const committerMetaSchema = z.object({
|
||||
committed: z
|
||||
.boolean()
|
||||
.describe("true if branch created, changes committed, and pushed successfully"),
|
||||
});
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
function committerPrompt(threadId: string): string {
|
||||
return `You are the committer agent. The coder finished with a passing build; your job is to branch, commit, and push.
|
||||
|
||||
1. Read the workflow thread: \`nerve thread show ${threadId}\` — understand what was planned, coded, and reviewed.
|
||||
2. Run \`git status\`. If nothing to commit, set committed=false.
|
||||
3. Create a feature branch: infer a good \`fix/<slug>\` or \`feat/<slug>\` name from the thread context.
|
||||
4. \`git add -A\`
|
||||
5. Write a conventional commit message based on the thread context.
|
||||
6. \`git commit -m "<message>"\` — do NOT pass \`--author\`, use repo git config.
|
||||
7. \`git push -u origin <branch>\`
|
||||
|
||||
**committed=true** only if branch was created, commit succeeded, and **push** succeeded.
|
||||
|
||||
End your reply with a JSON line:
|
||||
\`\`\`json
|
||||
{ "committed": true }
|
||||
\`\`\`
|
||||
or
|
||||
\`\`\`json
|
||||
{ "committed": false }
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a committer role that branches, commits, and pushes changes.
|
||||
* The agent reads the workflow thread to infer branch name, scope, and commit message.
|
||||
*/
|
||||
export function createCommitterRole(
|
||||
adapter: AgentFn,
|
||||
extract: LlmExtractorConfig,
|
||||
): Role<CommitterMeta> {
|
||||
const inner = createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => committerPrompt(start.meta.threadId),
|
||||
committerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
return decorateRole(inner, [
|
||||
withDryRun({ label: "committer", meta: { committed: true } as CommitterMeta }),
|
||||
onFail({ label: "committer", meta: { committed: false } as CommitterMeta }),
|
||||
]) as Role<CommitterMeta>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/nerve-role-reviewer",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-workflow-utils": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
export default defineConfig({
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
dts: true,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "node",
|
||||
cleanDistPath: true,
|
||||
},
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export { createReviewerRole, reviewerMetaSchema } from "./reviewer.js";
|
||||
export type { ReviewerMeta, ReviewerConfig } from "./reviewer.js";
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const reviewerMetaSchema = z.object({
|
||||
approved: z.boolean().describe("true if the diff is clean and ready to merge"),
|
||||
});
|
||||
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
|
||||
|
||||
export type ReviewerConfig = {
|
||||
/** Working directory of the project */
|
||||
cwd: string;
|
||||
/** Path to conventions/standards file, relative to cwd. Default: "CONVENTIONS.md" */
|
||||
conventionsPath: string | null;
|
||||
/** Extra checklist items appended to the review criteria */
|
||||
extraChecks: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
const defaults: ReviewerConfig = {
|
||||
cwd: ".",
|
||||
conventionsPath: "CONVENTIONS.md",
|
||||
extraChecks: [],
|
||||
};
|
||||
|
||||
function reviewerPrompt({
|
||||
threadId,
|
||||
config,
|
||||
}: { threadId: string; config: ReviewerConfig }): string {
|
||||
const { cwd, conventionsPath, extraChecks } = config;
|
||||
|
||||
const conventionsBlock = conventionsPath
|
||||
? `Read project conventions: \`cat ${cwd}/${conventionsPath}\`\n`
|
||||
: "";
|
||||
|
||||
const extraBlock =
|
||||
extraChecks.length > 0
|
||||
? `\n### 📋 Project-specific checks\n${extraChecks.map((c) => `- ${c}`).join("\n")}\n`
|
||||
: "";
|
||||
|
||||
return `You are a **code reviewer**. You run after the coder and before the tester.
|
||||
|
||||
**IMPORTANT: The project is at \`${cwd}\`. Always \`cd ${cwd}\` first.**
|
||||
|
||||
Read the workflow thread for context: \`nerve thread ${threadId}\`
|
||||
${conventionsBlock}
|
||||
## Your job — static analysis of the git diff
|
||||
|
||||
Run these commands and analyze the output:
|
||||
|
||||
1. **\`cd ${cwd} && git diff --stat\`** — see what files changed
|
||||
2. **\`cd ${cwd} && git diff\`** — read the actual diff
|
||||
3. **\`cd ${cwd} && git status --short\`** — check for untracked files
|
||||
|
||||
## Checklist
|
||||
|
||||
### 🔴 Reject (approved: false) — tell coder exactly what to fix
|
||||
- **Garbage files**: build artifacts, lockfiles, IDE config that shouldn't be committed
|
||||
- **Secrets/credentials**: API keys, tokens, passwords hardcoded in the diff
|
||||
- **Unrelated changes**: files modified outside the scope of the task
|
||||
${conventionsPath ? `- **Convention violations**: patterns that contradict ${conventionsPath}\n` : ""}${extraBlock}
|
||||
### ✅ Approve (approved: true) — no comment needed
|
||||
- Diff is clean, focused, follows project standards
|
||||
|
||||
End with:
|
||||
\`\`\`json
|
||||
{ "approved": true }
|
||||
\`\`\`
|
||||
or
|
||||
\`\`\`json
|
||||
{ "approved": false }
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reviewer role that performs static analysis on git diffs.
|
||||
* Checks for garbage files, secrets, unrelated changes, and project conventions.
|
||||
*/
|
||||
export function createReviewerRole(
|
||||
adapter: AgentFn,
|
||||
extract: LlmExtractorConfig,
|
||||
config: Partial<ReviewerConfig> = {},
|
||||
): Role<ReviewerMeta> {
|
||||
const resolved: ReviewerConfig = { ...defaults, ...config };
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => reviewerPrompt({ threadId: start.meta.threadId, config: resolved }),
|
||||
reviewerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/nerve-workflow-meta",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-role-committer": "workspace:*",
|
||||
"@uncaged/nerve-role-reviewer": "workspace:*",
|
||||
"@uncaged/nerve-workflow-utils": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
export default defineConfig({
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
dts: true,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "node",
|
||||
cleanDistPath: true,
|
||||
},
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
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 { moderator } from "./moderator.js";
|
||||
import type { SenseMeta } from "./moderator.js";
|
||||
import { createCoderRole } from "./roles/coder.js";
|
||||
import { createPlannerRole } from "./roles/planner.js";
|
||||
import { createTesterRole } from "./roles/tester.js";
|
||||
|
||||
export type CreateDevelopSenseDeps = {
|
||||
defaultAdapter: AgentFn;
|
||||
adapters: Partial<Record<keyof SenseMeta, AgentFn>> | null;
|
||||
extract: LlmExtractorConfig;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export function createDevelopSenseWorkflow({
|
||||
defaultAdapter,
|
||||
adapters,
|
||||
extract,
|
||||
cwd,
|
||||
}: CreateDevelopSenseDeps): WorkflowDefinition<SenseMeta> {
|
||||
const a = (role: keyof SenseMeta) => adapters?.[role] ?? defaultAdapter;
|
||||
const roles = {
|
||||
planner: createPlannerRole(a("planner"), extract),
|
||||
coder: createCoderRole(a("coder"), extract),
|
||||
reviewer: createReviewerRole(a("reviewer"), extract, {
|
||||
cwd,
|
||||
conventionsPath: "CONVENTIONS.md",
|
||||
}),
|
||||
tester: createTesterRole(a("tester"), extract, cwd),
|
||||
committer: createCommitterRole(a("committer"), extract),
|
||||
};
|
||||
|
||||
return {
|
||||
name: "develop-sense",
|
||||
roles,
|
||||
moderator,
|
||||
};
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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 type { CoderMeta } from "./roles/coder.js";
|
||||
import type { PlannerMeta } from "./roles/planner.js";
|
||||
import type { TesterMeta } from "./roles/tester.js";
|
||||
|
||||
export type SenseMeta = {
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
tester: TesterMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
const MAX_CODER_ROUNDS = 20;
|
||||
const MAX_TOTAL_REJECTIONS = 10;
|
||||
|
||||
function coderRounds(steps: { role: string }[]): number {
|
||||
return steps.filter((s) => s.role === "coder").length;
|
||||
}
|
||||
|
||||
function totalRejections(steps: { role: string; meta: unknown }[]): number {
|
||||
return steps.filter((s) => {
|
||||
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved;
|
||||
if (s.role === "tester") return !(s.meta as Record<string, boolean>).passed;
|
||||
if (s.role === "committer") return !(s.meta as Record<string, boolean>).committed;
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function canRetryCoder(steps: { role: string; meta: unknown }[]): boolean {
|
||||
return coderRounds(steps) < MAX_CODER_ROUNDS && totalRejections(steps) < MAX_TOTAL_REJECTIONS;
|
||||
}
|
||||
|
||||
export const moderator: Moderator<SenseMeta> = (context) => {
|
||||
if (context.steps.length === 0) return "planner";
|
||||
|
||||
const last = context.steps[context.steps.length - 1];
|
||||
|
||||
if (last.role === "planner") return "coder";
|
||||
|
||||
if (last.role === "coder") {
|
||||
if (last.meta.filesCreated) return "reviewer";
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "reviewer") {
|
||||
if (last.meta.approved) return "tester";
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "tester") {
|
||||
if (last.meta.passed) return "committer";
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
if (last.meta.committed) return END;
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
return END;
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
filesCreated: z.boolean().describe("true if the sense files were created"),
|
||||
});
|
||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
export function coderPrompt({ threadId }: { threadId: string }): string {
|
||||
return `Read the workflow thread for the planner's sense design and any tester feedback: \`nerve thread ${threadId}\`
|
||||
Read the nerve-dev skill for sense file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
|
||||
|
||||
## Your task
|
||||
|
||||
Implement (or fix) the sense the planner designed. If there is tester feedback in the thread, fix the issues it identified.
|
||||
|
||||
## Multi-step approach
|
||||
|
||||
You do NOT need to finish everything in one pass. You may return \`done: false\` to continue in the next iteration.
|
||||
|
||||
## File structure for each sense
|
||||
|
||||
- \`senses/<name>/src/index.ts\` — TypeScript compute source; import schema as \`./schema.ts\`
|
||||
- \`senses/<name>/src/schema.ts\` — Drizzle schema (TypeScript)
|
||||
- \`senses/<name>/migrations/\` — Drizzle migration files (at sense root, not inside src/)
|
||||
- \`senses/<name>/package.json\` — with esbuild build script
|
||||
- \`senses/<name>/index.js\` — bundled output generated by \`pnpm build\` (do NOT edit by hand)
|
||||
|
||||
Look at existing senses for the package.json template and patterns.
|
||||
|
||||
## When to return done: true
|
||||
|
||||
Return \`done: true\` ONLY when ALL of the following are true:
|
||||
- All required files are created
|
||||
- \`pnpm install --no-cache && pnpm build\` succeeds (run it!)
|
||||
- \`nerve.yaml\` is updated with the sense config
|
||||
|
||||
Return \`done: false\` if you made progress but there is still work to do.`;
|
||||
}
|
||||
|
||||
export function createCoderRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CoderMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
|
||||
coderMetaSchema,
|
||||
extract,
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
senseName: z.string().describe("kebab-case sense name from the plan"),
|
||||
});
|
||||
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||
|
||||
export function plannerPrompt({ threadId }: { threadId: string }): string {
|
||||
return `You are planning a new Nerve sense.
|
||||
|
||||
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
|
||||
Read the nerve-dev skill for sense conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
|
||||
Also look at existing senses in the \`senses/\` directory for patterns.
|
||||
|
||||
Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown:
|
||||
|
||||
## Sense Design
|
||||
### Name — kebab-case
|
||||
### Fields — name, type (integer/real/text), description
|
||||
### Compute Logic — step-by-step, specific Node.js APIs or shell commands
|
||||
### Trigger Config — group, interval, throttle, timeout
|
||||
|
||||
Output ONLY the plan. Be precise and implementation-ready.`;
|
||||
}
|
||||
|
||||
export function createPlannerRole(
|
||||
adapter: AgentFn,
|
||||
extract: LlmExtractorConfig,
|
||||
): Role<PlannerMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
|
||||
plannerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const testerMetaSchema = z.object({
|
||||
passed: z.boolean().describe("true if all e2e checks passed"),
|
||||
});
|
||||
export type TesterMeta = z.infer<typeof testerMetaSchema>;
|
||||
|
||||
export function testerPrompt({
|
||||
threadId,
|
||||
nerveRoot,
|
||||
}: { threadId: string; nerveRoot: string }): string {
|
||||
return `You are testing a newly created Nerve sense end-to-end.
|
||||
|
||||
**IMPORTANT: The Nerve workspace is at \`${nerveRoot}\`. All paths below are relative to this directory. Always \`cd ${nerveRoot}\` first.**
|
||||
|
||||
Read the workflow thread for context: \`nerve thread ${threadId}\`
|
||||
Read the nerve-dev skill for expected file structure: \`cat ${nerveRoot}/node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
|
||||
|
||||
Verify the full lifecycle in this order:
|
||||
|
||||
1. **File check** — all required sense files exist:
|
||||
- \`senses/<name>/src/index.ts\`
|
||||
- \`senses/<name>/src/schema.ts\`
|
||||
- \`senses/<name>/migrations/\`
|
||||
- \`senses/<name>/package.json\`
|
||||
|
||||
2. **Build** — run inside the sense directory:
|
||||
\`\`\`
|
||||
cd ${nerveRoot}/senses/<name> && pnpm install --no-cache && pnpm build
|
||||
\`\`\`
|
||||
Must produce \`index.js\` at sense root without errors.
|
||||
|
||||
3. **Config check** — \`nerve validate\` passes, confirming nerve.yaml is valid.
|
||||
|
||||
4. **Sense list** — \`nerve sense list\` shows the sense.
|
||||
|
||||
5. **Trigger** — \`nerve sense trigger <name>\` completes without error.
|
||||
|
||||
6. **Query** — \`nerve sense query <name>\` — retry up to 20s until rows appear.
|
||||
|
||||
If any step fails, include the relevant error output.
|
||||
|
||||
Output a clear summary: what you checked, what passed, what failed, and why.`;
|
||||
}
|
||||
|
||||
export function createTesterRole(
|
||||
adapter: AgentFn,
|
||||
extract: LlmExtractorConfig,
|
||||
nerveRoot: string,
|
||||
): Role<TesterMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => testerPrompt({ threadId: start.meta.threadId, nerveRoot }),
|
||||
testerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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 { moderator } from "./moderator.js";
|
||||
import type { WorkflowMeta } from "./moderator.js";
|
||||
import { createCoderRole } from "./roles/coder.js";
|
||||
import { createPlannerRole } from "./roles/planner.js";
|
||||
import { createTesterRole } from "./roles/tester.js";
|
||||
|
||||
export type CreateDevelopWorkflowDeps = {
|
||||
defaultAdapter: AgentFn;
|
||||
adapters: Partial<Record<keyof WorkflowMeta, AgentFn>> | null;
|
||||
extract: LlmExtractorConfig;
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
export function createDevelopWorkflowWorkflow({
|
||||
defaultAdapter,
|
||||
adapters,
|
||||
extract,
|
||||
nerveRoot,
|
||||
}: CreateDevelopWorkflowDeps): WorkflowDefinition<WorkflowMeta> {
|
||||
const a = (role: keyof WorkflowMeta) => adapters?.[role] ?? defaultAdapter;
|
||||
const roles = {
|
||||
planner: createPlannerRole(a("planner"), extract),
|
||||
coder: createCoderRole(a("coder"), extract),
|
||||
reviewer: createReviewerRole(a("reviewer"), extract, {
|
||||
cwd: nerveRoot,
|
||||
conventionsPath: "CONVENTIONS.md",
|
||||
}),
|
||||
tester: createTesterRole(a("tester"), extract, nerveRoot),
|
||||
committer: createCommitterRole(a("committer"), extract),
|
||||
};
|
||||
|
||||
return {
|
||||
name: "develop-workflow",
|
||||
roles,
|
||||
moderator,
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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 type { CoderMeta } from "./roles/coder.js";
|
||||
import type { PlannerMeta } from "./roles/planner.js";
|
||||
import type { TesterMeta } from "./roles/tester.js";
|
||||
|
||||
export type WorkflowMeta = {
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
tester: TesterMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
const MAX_CODER_ROUNDS = 20;
|
||||
const MAX_TOTAL_REJECTIONS = 10;
|
||||
|
||||
function coderRounds(steps: { role: string }[]): number {
|
||||
return steps.filter((s) => s.role === "coder").length;
|
||||
}
|
||||
|
||||
function totalRejections(steps: { role: string; meta: unknown }[]): number {
|
||||
return steps.filter((s) => {
|
||||
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved;
|
||||
if (s.role === "tester") return !(s.meta as Record<string, boolean>).passed;
|
||||
if (s.role === "committer") return !(s.meta as Record<string, boolean>).committed;
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function canRetryCoder(steps: { role: string; meta: unknown }[]): boolean {
|
||||
return coderRounds(steps) < MAX_CODER_ROUNDS && totalRejections(steps) < MAX_TOTAL_REJECTIONS;
|
||||
}
|
||||
|
||||
export const moderator: Moderator<WorkflowMeta> = (context) => {
|
||||
if (context.steps.length === 0) return "planner";
|
||||
|
||||
const last = context.steps[context.steps.length - 1];
|
||||
|
||||
if (last.role === "planner") {
|
||||
return last.meta.ready ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "coder") {
|
||||
if (last.meta.done) return "reviewer";
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "reviewer") {
|
||||
if (last.meta.approved) return "tester";
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "tester") {
|
||||
if (last.meta.passed) return "committer";
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
if (last.meta.committed) return END;
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
return END;
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
done: z.boolean().describe("true if the workflow files were created and build passes"),
|
||||
});
|
||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
export function coderPrompt({ threadId }: { threadId: string }): string {
|
||||
return `Read the workflow thread to get the planner's design and any reviewer/tester/committer feedback: \`nerve thread ${threadId}\`
|
||||
Read the nerve-dev skill for workflow file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
|
||||
Also look at existing workflows in the \`workflows/\` directory for patterns.
|
||||
|
||||
## Your task
|
||||
|
||||
Implement the planner's design. This may be **creating a new workflow** or **modifying an existing one**. If there is reviewer, tester, or committer feedback in the thread, fix the issues they identified.
|
||||
|
||||
**IMPORTANT:** The thread contains both the **initial user prompt** (the first message) and the **planner's design**. Read both carefully:
|
||||
- The **initial prompt** contains the user's specific requirements for role behavior, tools to use, and acceptance criteria
|
||||
- The **planner's design** contains the architecture, file structure, and routing logic
|
||||
- When writing role prompts, follow the user's behavioral requirements from the initial prompt — do not invent your own interpretation
|
||||
|
||||
## Multi-step approach
|
||||
|
||||
You do NOT need to finish everything in one pass. You may return \`done: false\` to continue in the next iteration. For example:
|
||||
1. First pass: scaffold files / make structural changes
|
||||
2. Second pass: implement role logic
|
||||
3. Third pass: fix build/lint errors
|
||||
|
||||
## Workflow file structure
|
||||
|
||||
Each workflow must have:
|
||||
- \`workflows/<name>/index.ts\` — WorkflowDefinition default export
|
||||
- \`workflows/<name>/build.ts\` — factory function
|
||||
- \`workflows/<name>/moderator.ts\` — moderator + meta types
|
||||
- \`workflows/<name>/roles/<role>.ts\` — meta schema and prompt function per role
|
||||
- \`workflows/<name>/package.json\` — with esbuild build script
|
||||
- \`workflows/<name>/tsconfig.json\` — TypeScript config
|
||||
|
||||
For **new workflows**, also update \`nerve.yaml\` with \`workflows.<name>\`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep the WorkflowDefinition<WorkflowMeta> pattern
|
||||
- No dynamic import()
|
||||
- Use types (not interfaces)
|
||||
- Meta should be simple routing signals (single boolean per role)
|
||||
- Write compile-ready TypeScript
|
||||
|
||||
## When to return done: true
|
||||
|
||||
Return \`done: true\` ONLY when ALL of the following are true:
|
||||
- All changes from the plan are implemented
|
||||
- \`cd workflows/<name> && pnpm install --no-cache && pnpm build\` succeeds (run it!)
|
||||
- No lint or type errors remain
|
||||
|
||||
Return \`done: false\` if you made progress but there is still work to do, or if build/lint has errors you plan to fix in the next iteration.`;
|
||||
}
|
||||
|
||||
export function createCoderRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CoderMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
|
||||
coderMetaSchema,
|
||||
extract,
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
ready: z.boolean().describe("true if requirements are clear and a workflow can be implemented"),
|
||||
});
|
||||
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||
|
||||
export function plannerPrompt({ threadId }: { threadId: string }): string {
|
||||
return `You are a Nerve workflow planner. You can **create new workflows** or **modify existing ones**.
|
||||
|
||||
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
|
||||
Read the nerve-dev skill for workflow conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
|
||||
List existing workflows: \`ls workflows/\`
|
||||
|
||||
## Determine the task type
|
||||
|
||||
1. If the user wants to **modify an existing workflow** — read its current code (\`cat workflows/<name>/moderator.ts\`, \`cat workflows/<name>/build.ts\`, \`ls workflows/<name>/roles/\`, etc.) and understand its current structure before planning changes.
|
||||
2. If the user wants to **create a new workflow** — look at existing workflows in \`workflows/\` for patterns to follow.
|
||||
|
||||
## Produce a PLAN (not code) in markdown
|
||||
|
||||
For **new workflows**:
|
||||
- Workflow name (kebab-case)
|
||||
- Roles list (name, purpose, tool)
|
||||
- Flow transitions / moderator routing logic
|
||||
- Validation loops design
|
||||
- External dependencies
|
||||
- Data flow between roles
|
||||
|
||||
For **modifications to existing workflows**:
|
||||
- Workflow name (existing)
|
||||
- What changes are needed and why
|
||||
- Files to add/modify/delete
|
||||
- Impact on moderator routing logic (this workflow's typical order is planner → coder → reviewer → tester → committer)
|
||||
- Backward compatibility considerations (if any)
|
||||
|
||||
**For every role (new or modified)**, include a **Role Behavior** section that describes:
|
||||
- What the role should do, check, or produce
|
||||
- What tools or commands it should use
|
||||
- What criteria determine its meta output (e.g. approved/passed/done)
|
||||
- Preserve the user's specific requirements verbatim — do NOT summarize away details
|
||||
|
||||
If requirements are NOT clear, describe what is missing or ambiguous.
|
||||
|
||||
End your response with a JSON block:
|
||||
\`\`\`json
|
||||
{ "ready": true }
|
||||
\`\`\`
|
||||
or
|
||||
\`\`\`json
|
||||
{ "ready": false }
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
export function createPlannerRole(
|
||||
adapter: AgentFn,
|
||||
extract: LlmExtractorConfig,
|
||||
): Role<PlannerMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
|
||||
plannerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const testerMetaSchema = z.object({
|
||||
passed: z.boolean().describe("true if all validation checks passed"),
|
||||
});
|
||||
export type TesterMeta = z.infer<typeof testerMetaSchema>;
|
||||
|
||||
export function testerPrompt({
|
||||
threadId,
|
||||
nerveRoot,
|
||||
}: { threadId: string; nerveRoot: string }): string {
|
||||
return `You are testing a Nerve workflow — either newly created or recently modified.
|
||||
|
||||
**IMPORTANT: The Nerve workspace is at \`${nerveRoot}\`. All paths below are relative to this directory. Always \`cd ${nerveRoot}\` first.**
|
||||
|
||||
Read the workflow thread for context: \`nerve thread ${threadId}\`
|
||||
Read the nerve-dev skill for expected file structure: \`cat ${nerveRoot}/node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
|
||||
|
||||
Get the workflow name from the thread (the planner's output).
|
||||
|
||||
Verify the full lifecycle in this order:
|
||||
|
||||
1. **File check** — all required workflow files exist (under \`${nerveRoot}/\`):
|
||||
- \`workflows/<name>/index.ts\`
|
||||
- \`workflows/<name>/build.ts\`
|
||||
- \`workflows/<name>/moderator.ts\`
|
||||
- \`workflows/<name>/roles/\` with one \`.ts\` file per role
|
||||
- \`workflows/<name>/package.json\`
|
||||
|
||||
2. **Build** — run inside the workflow directory:
|
||||
\`\`\`
|
||||
cd ${nerveRoot}/workflows/<name> && pnpm install --no-cache && pnpm build
|
||||
\`\`\`
|
||||
Must produce \`dist/index.js\` without errors.
|
||||
|
||||
3. **Config check** — \`cd ${nerveRoot} && nerve validate\` passes, confirming nerve.yaml is valid.
|
||||
|
||||
4. **Workflow list** — \`nerve workflow list\` shows the workflow.
|
||||
|
||||
5. **Trigger test** — \`nerve workflow trigger <name> --dry-run\` if available, otherwise just confirm the workflow appears in \`nerve workflow status\`.
|
||||
|
||||
If any step fails, include the relevant error output.
|
||||
|
||||
Output a clear summary: what you checked, what passed, what failed, and why.`;
|
||||
}
|
||||
|
||||
export function createTesterRole(
|
||||
adapter: AgentFn,
|
||||
extract: LlmExtractorConfig,
|
||||
nerveRoot: string,
|
||||
): Role<TesterMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => testerPrompt({ threadId: start.meta.threadId, nerveRoot }),
|
||||
testerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { createDevelopSenseWorkflow } from "./develop-sense/build.js";
|
||||
export type { CreateDevelopSenseDeps } from "./develop-sense/build.js";
|
||||
export { createDevelopWorkflowWorkflow } from "./develop-workflow/build.js";
|
||||
export type { CreateDevelopWorkflowDeps } from "./develop-workflow/build.js";
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import type {
|
||||
AgentFn,
|
||||
ModeratorContext,
|
||||
RoleMeta,
|
||||
WorkflowContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END, START } from "@uncaged/nerve-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createRole } from "../create-role.js";
|
||||
import * as extractFn from "../shared/extract-fn.js";
|
||||
|
||||
const provider = {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
};
|
||||
|
||||
function toolCallResponse(argsJson: string): {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text: () => Promise<string>;
|
||||
} {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: argsJson,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStart(threadId: string): {
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: { maxRounds: number; dryRun: boolean; threadId: string };
|
||||
timestamp: number;
|
||||
} {
|
||||
return {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: { maxRounds: 10, dryRun: false, threadId },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createRole", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("runs AgentFn then structured extract", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 3 }))));
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const adapter: AgentFn = async (prompt) => prompt;
|
||||
const role = createRole(adapter, "hello", schema, { provider });
|
||||
|
||||
const out = await role(makeStart("t1"), []);
|
||||
expect(out.content).toBe("hello");
|
||||
expect(out.meta).toEqual({ n: 3 });
|
||||
});
|
||||
|
||||
it("passes WorkflowContext with workdir defaulting to process.cwd()", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 0 }))));
|
||||
|
||||
const seen: WorkflowContext[] = [];
|
||||
const adapter: AgentFn = async (_prompt, ctx) => {
|
||||
seen.push(ctx);
|
||||
return "x";
|
||||
};
|
||||
const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider });
|
||||
await role(makeStart("t1"), []);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].workdir).toBe(process.cwd());
|
||||
});
|
||||
|
||||
it("resolves dynamic prompt functions before AgentFn", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 99 }))));
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const adapter: AgentFn = async (prompt) => prompt;
|
||||
const role = createRole(
|
||||
adapter,
|
||||
async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`,
|
||||
schema,
|
||||
{ provider },
|
||||
);
|
||||
|
||||
const start = makeStart("thread-x");
|
||||
const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }];
|
||||
const out = await role(start, msgs);
|
||||
expect(out.content).toBe("tid=thread-x n=1");
|
||||
expect(out.meta).toEqual({ n: 99 });
|
||||
});
|
||||
|
||||
it("uses start.meta.dryRun when extract.dryRun is omitted", async () => {
|
||||
const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
|
||||
|
||||
const adapter: AgentFn = async () => "raw";
|
||||
const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider });
|
||||
const start = {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: { maxRounds: 10, dryRun: true, threadId: "x" },
|
||||
timestamp: 1,
|
||||
};
|
||||
await role(start, []);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"raw",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider, dryRun: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers extract.dryRun over start.meta.dryRun", async () => {
|
||||
const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
|
||||
|
||||
const adapter: AgentFn = async () => "raw";
|
||||
const role = createRole(adapter, "p", z.object({ n: z.number() }), {
|
||||
provider,
|
||||
dryRun: false,
|
||||
});
|
||||
const start = {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: { maxRounds: 10, dryRun: true, threadId: "x" },
|
||||
timestamp: 1,
|
||||
};
|
||||
await role(start, []);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"raw",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ dryRun: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowDefinition compatibility", () => {
|
||||
it("hand-written Role-based WorkflowDefinition remains valid", async () => {
|
||||
type M = RoleMeta & { legacy: { id: string } };
|
||||
|
||||
const manual: WorkflowDefinition<M> = {
|
||||
name: "legacy",
|
||||
roles: {
|
||||
legacy: async (_start, _messages) => ({
|
||||
content: "hi",
|
||||
meta: { id: "a" },
|
||||
}),
|
||||
},
|
||||
moderator: (_ctx: ModeratorContext<M>) => END,
|
||||
};
|
||||
|
||||
const start = makeStart("t1");
|
||||
const out = await manual.roles.legacy(start, []);
|
||||
expect(out.content).toBe("hi");
|
||||
expect(out.meta.id).toBe("a");
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Role, StartStep } from "@uncaged/nerve-core";
|
||||
|
||||
import { START } from "@uncaged/nerve-core";
|
||||
import { decorateRole, onFail, withDryRun } from "../role-decorators.js";
|
||||
|
||||
type TestMeta = Record<string, unknown> & { ok: boolean };
|
||||
|
||||
function fakeStart(dryRun: boolean): StartStep {
|
||||
return {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: {
|
||||
threadId: "t1",
|
||||
dryRun,
|
||||
maxRounds: 10,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const successRole: Role<TestMeta> = async () => ({
|
||||
content: "done",
|
||||
meta: { ok: true },
|
||||
});
|
||||
|
||||
const failRole: Role<TestMeta> = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
|
||||
const failNonErrorRole: Role<TestMeta> = async () => {
|
||||
throw "string error";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withDryRun
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("withDryRun", () => {
|
||||
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true } });
|
||||
|
||||
it("short-circuits on dry-run", async () => {
|
||||
const role = dec(successRole);
|
||||
const result = await role(fakeStart(true), []);
|
||||
expect(result.content).toBe("[dry-run] test skipped");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("delegates when not dry-run", async () => {
|
||||
const role = dec(successRole);
|
||||
const result = await role(fakeStart(false), []);
|
||||
expect(result.content).toBe("done");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onFail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("onFail", () => {
|
||||
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
|
||||
|
||||
it("passes through on success", async () => {
|
||||
const role = dec(successRole);
|
||||
const result = await role(fakeStart(false), []);
|
||||
expect(result.content).toBe("done");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("catches Error and returns structured failure", async () => {
|
||||
const role = dec(failRole);
|
||||
const result = await role(fakeStart(false), []);
|
||||
expect(result.content).toBe("test failed: boom");
|
||||
expect(result.meta).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("catches non-Error throws", async () => {
|
||||
const role = dec(failNonErrorRole);
|
||||
const result = await role(fakeStart(false), []);
|
||||
expect(result.content).toBe("test failed: string error");
|
||||
expect(result.meta).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decorateRole
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("decorateRole", () => {
|
||||
it("applies decorators left-to-right", async () => {
|
||||
const role = decorateRole(failRole, [
|
||||
withDryRun({ label: "x", meta: { ok: true } }),
|
||||
onFail({ label: "x", meta: { ok: false } }),
|
||||
]);
|
||||
// Not dry-run, so withDryRun passes through → failRole throws → onFail catches
|
||||
const result = await role(fakeStart(false), []);
|
||||
expect(result.content).toBe("x failed: boom");
|
||||
expect(result.meta).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("dry-run short-circuits before onFail", async () => {
|
||||
const role = decorateRole(failRole, [
|
||||
withDryRun({ label: "x", meta: { ok: true } }),
|
||||
onFail({ label: "x", meta: { ok: false } }),
|
||||
]);
|
||||
const result = await role(fakeStart(true), []);
|
||||
expect(result.content).toBe("[dry-run] x skipped");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type {
|
||||
AgentFn,
|
||||
Role,
|
||||
StartStep,
|
||||
WorkflowContext,
|
||||
WorkflowMessage,
|
||||
} from "@uncaged/nerve-core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { extractMetaOrThrow } from "./shared/extract-fn.js";
|
||||
import type { LlmProvider } from "./shared/llm-extract.js";
|
||||
|
||||
type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
|
||||
|
||||
export type LlmExtractorConfig = {
|
||||
provider: LlmProvider;
|
||||
/** When omitted, uses `start.meta.dryRun` at runtime. */
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
type StartMetaWithWorkdir = StartStep["meta"] & { workdir?: string | null };
|
||||
|
||||
function resolveWorkdir(start: StartStep): string {
|
||||
const m = start.meta as StartMetaWithWorkdir;
|
||||
return m.workdir ?? process.cwd();
|
||||
}
|
||||
|
||||
function resolveDryRun(extract: LlmExtractorConfig, start: StartStep): boolean {
|
||||
return extract.dryRun ?? start.meta.dryRun;
|
||||
}
|
||||
|
||||
/** Builds a Role from an AgentFn, prompt, Zod meta schema, and LLM extract config. */
|
||||
export function createRole<M extends Record<string, unknown>>(
|
||||
adapter: AgentFn,
|
||||
prompt: PromptInput,
|
||||
meta: z.ZodType<M>,
|
||||
extract: LlmExtractorConfig,
|
||||
): Role<M> {
|
||||
return async (start: StartStep, messages: WorkflowMessage[]) => {
|
||||
const ctx: WorkflowContext = {
|
||||
start,
|
||||
messages,
|
||||
workdir: resolveWorkdir(start),
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
const promptText = typeof prompt === "string" ? prompt : await prompt(start, messages);
|
||||
const raw = await adapter(promptText, ctx);
|
||||
const result = await extractMetaOrThrow(raw, meta, {
|
||||
provider: extract.provider,
|
||||
dryRun: resolveDryRun(extract, start),
|
||||
});
|
||||
return { content: raw, meta: result };
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Primary API — role factory templates
|
||||
export { createRole, type LlmExtractorConfig } from "./create-role.js";
|
||||
export { createCursorRole } from "./role-cursor.js";
|
||||
export { createHermesRole } from "./role-hermes.js";
|
||||
export { createLlmRole } from "./role-llm.js";
|
||||
@@ -10,7 +9,6 @@ export {
|
||||
assertZodMetaSchemas,
|
||||
createLlmExtractFn,
|
||||
extractMetaOrThrow,
|
||||
zodMeta,
|
||||
type ZodMetaSchema,
|
||||
} from "./shared/extract-fn.js";
|
||||
export {
|
||||
@@ -20,14 +18,6 @@ export {
|
||||
type ReadNerveYamlOptions,
|
||||
} from "./shared/context.js";
|
||||
export { isDryRun } from "./role-types.js";
|
||||
export {
|
||||
decorateRole,
|
||||
withDryRun,
|
||||
onFail,
|
||||
type RoleDecorator,
|
||||
type WithDryRunOptions,
|
||||
type OnFailOptions,
|
||||
} from "./role-decorators.js";
|
||||
export {
|
||||
nerveCommandEnv,
|
||||
spawnSafe,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
|
||||
import { isDryRun } from "./role-types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decorator types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A role decorator: takes a role, returns an enhanced role. */
|
||||
export type RoleDecorator<M extends Record<string, unknown>> = (role: Role<M>) => Role<M>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decorateRole — compose a chain of decorators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply an ordered list of decorators to a role.
|
||||
* Decorators are applied left-to-right (first in list wraps innermost).
|
||||
*
|
||||
* ```ts
|
||||
* decorateRole(role, [withDryRun(opts), onFail(opts)]);
|
||||
* // equivalent to: onFail(opts)(withDryRun(opts)(role))
|
||||
* ```
|
||||
*/
|
||||
export function decorateRole<M extends Record<string, unknown>>(
|
||||
role: Role<M>,
|
||||
decorators: RoleDecorator<M>[],
|
||||
): Role<M> {
|
||||
return decorators.reduce((r, dec) => dec(r), role);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withDryRun — skip execution when dry-run is active
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WithDryRunOptions<M> = {
|
||||
/** Used in skip message (e.g. "committer", "publish"). */
|
||||
label: string;
|
||||
/** Meta returned when dry-run skips execution. */
|
||||
meta: M;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a decorator that short-circuits with a stable result when
|
||||
* `start.meta.dryRun` is true.
|
||||
*/
|
||||
export function withDryRun<M extends Record<string, unknown>>(
|
||||
opts: WithDryRunOptions<M>,
|
||||
): RoleDecorator<M> {
|
||||
return (role) => async (start: StartStep, messages: WorkflowMessage[]) => {
|
||||
if (isDryRun(start)) {
|
||||
return {
|
||||
content: `[dry-run] ${opts.label} skipped`,
|
||||
meta: opts.meta,
|
||||
};
|
||||
}
|
||||
return role(start, messages);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onFail — catch errors and return a structured failure result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OnFailOptions<M> = {
|
||||
/** Used in failure message (e.g. "committer", "publish"). */
|
||||
label: string;
|
||||
/** Meta returned when the inner role throws. */
|
||||
meta: M;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a decorator that catches thrown errors and converts them into
|
||||
* a structured RoleResult instead of propagating.
|
||||
*/
|
||||
export function onFail<M extends Record<string, unknown>>(
|
||||
opts: OnFailOptions<M>,
|
||||
): RoleDecorator<M> {
|
||||
return (role) => async (start: StartStep, messages: WorkflowMessage[]) => {
|
||||
try {
|
||||
return await role(start, messages);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: `${opts.label} failed: ${msg}`,
|
||||
meta: opts.meta,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,11 +10,6 @@ import { llmErrorToCause, llmExtractWithRetry } from "./llm-extract.js";
|
||||
*/
|
||||
export type ZodMetaSchema<T> = Schema<T> & { readonly zod: z.ZodType<T> };
|
||||
|
||||
/** Builds a core `Schema<T>` plus Zod parser for `createRole` meta / `createLlmExtractFn`. */
|
||||
export function zodMeta<T>(zod: z.ZodType<T>): ZodMetaSchema<T> {
|
||||
return { witness: null, zod };
|
||||
}
|
||||
|
||||
export async function extractMetaOrThrow<T>(
|
||||
raw: string,
|
||||
zodSchema: z.ZodType<T>,
|
||||
@@ -50,8 +45,8 @@ export function createLlmExtractFn<T>(deps: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that all schemas are ZodMetaSchema before any role is invoked.
|
||||
* Call this once at daemon startup / hot-reload when wiring roles manually.
|
||||
* Validate that all schemas in a WorkflowSpec are ZodMetaSchema at compile time,
|
||||
* before any role is ever invoked. Call this once at daemon startup / hot-reload.
|
||||
*/
|
||||
export function assertZodMetaSchemas(schemas: Record<string, Schema<unknown>>): void {
|
||||
for (const [roleName, schema] of Object.entries(schemas)) {
|
||||
|
||||
Generated
-76
@@ -86,9 +86,6 @@ importers:
|
||||
|
||||
packages/core:
|
||||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: 1.0.0-beta.23-c10d10c
|
||||
version: 1.0.0-beta.23-c10d10c(@cloudflare/workers-types@4.20260425.1)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.11(@azure/core-client@1.10.1))(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(sql.js@1.14.1)(zod@4.3.6)
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.8.3
|
||||
@@ -159,50 +156,6 @@ importers:
|
||||
specifier: ^4.14.0
|
||||
version: 4.85.0(@cloudflare/workers-types@4.20260425.1)
|
||||
|
||||
packages/role-committer:
|
||||
dependencies:
|
||||
'@uncaged/nerve-core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@uncaged/nerve-workflow-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-utils
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@rslib/core':
|
||||
specifier: ^0.21.3
|
||||
version: 0.21.3(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
|
||||
|
||||
packages/role-reviewer:
|
||||
dependencies:
|
||||
'@uncaged/nerve-core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@uncaged/nerve-workflow-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-utils
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@rslib/core':
|
||||
specifier: ^0.21.3
|
||||
version: 0.21.3(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
|
||||
|
||||
packages/skills: {}
|
||||
|
||||
packages/store:
|
||||
@@ -221,34 +174,6 @@ 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-meta:
|
||||
dependencies:
|
||||
'@uncaged/nerve-core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@uncaged/nerve-role-committer':
|
||||
specifier: workspace:*
|
||||
version: link:../role-committer
|
||||
'@uncaged/nerve-role-reviewer':
|
||||
specifier: workspace:*
|
||||
version: link:../role-reviewer
|
||||
'@uncaged/nerve-workflow-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-utils
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@rslib/core':
|
||||
specifier: ^0.21.3
|
||||
version: 0.21.3(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
|
||||
|
||||
packages/workflow-utils:
|
||||
dependencies:
|
||||
'@uncaged/nerve-adapter-cursor':
|
||||
@@ -2036,7 +1961,6 @@ packages:
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
vite@8.0.9:
|
||||
|
||||
Reference in New Issue
Block a user