Compare commits

...

151 Commits

Author SHA1 Message Date
xiaoju 21be4997df feat(workflow-utils): add dryRunDefaults option to llmExtract
Lets workflow authors provide semantically meaningful mock data for dryRun mode. Falls back to schemaDefaults() when not provided.

Fixes #130

小橘 🍊(NEKO Team)

Made-with: Cursor
2026-04-25 04:40:32 +00:00
xiaomo ce20d73ab6 Merge pull request 'fix(workflow-utils): llmExtract dryRun returns schema-shaped defaults' (#126) from fix/123-llmextract-dryrun-defaults into main 2026-04-25 04:35:45 +00:00
xiaoju 7c999a0689 fix(workflow-utils): dryRun llmExtract returns schema-shaped defaults
Add schemaDefaults() from Zod def types; export from package; tests for nested/array/enum/optional.

Made-with: Cursor
2026-04-25 04:31:46 +00:00
xiaomo 111b7e2734 Merge pull request 'feat: workflow exit codes & kill mechanism' (#122) from feat/121-workflow-exit-codes into main 2026-04-25 04:03:29 +00:00
xiaoju 01d7435c4a feat: workflow exit codes & kill mechanism
- Add exit_code to workflow_runs (0=success, 1=role error, 2=maxRounds, 137=killed, 255=crash)
- Expand status enum: started/completed/failed/killed
- Add kill-thread IPC message for graceful workflow termination
- Add 'nerve workflow kill <runId>' CLI command
- Show exit_code in 'nerve workflow list' output

Fixes #121
2026-04-25 03:57:26 +00:00
xiaoju 889bbbb474 Merge pull request 'refactor(core): SenseResult<T> generic + split types.ts' (#118) from refactor/111-split-types-generify-sense-result into main 2026-04-25 02:59:48 +00:00
xiaoju 418ae6a073 refactor(core): SenseResult generic + split types.ts into config/sense/workflow
- SenseResult<T = unknown> with payload: T
- types.ts split into config.ts (types), sense.ts, workflow.ts
- Original config.ts (parseNerveConfig) moved to parse-nerve-config.ts
- index.ts re-exports from new modules, external API unchanged
- daemon-ipc-protocol.ts imports SenseInfo from sense.ts

Fixes #111
2026-04-25 02:56:55 +00:00
xiaoju c6f56155c8 Merge pull request 'refactor(core): restructure ModeratorContext to { start, steps }' (#117) from refactor/110-moderator-context-restructure into main 2026-04-25 02:51:50 +00:00
xiaoju 3ce9e3a846 refactor(core): restructure ModeratorContext to { start, steps }
- ModeratorContext: discriminated union → { start: StartStep; steps: RoleStep<M>[] }
- Moderator signature: (context, round, maxRounds) → (context)
- round derivable from steps.length, maxRounds from start.meta.maxRounds
- workflow-worker.ts: build steps array, pass full context to moderator
- Remove unused ModeratorContext import from workflow-worker
- Update README.md

Refs #110
2026-04-25 02:48:28 +00:00
xiaoju 0fff8ef954 Merge pull request 'refactor(core): rename RoleSignal → RoleStep, StartSignal → StartStep' (#116) from refactor/109-role-step into main 2026-04-25 02:37:03 +00:00
xiaoju beada2ae09 refactor(core): rename RoleSignal → RoleStep, StartSignal → StartStep
- RoleStep now includes content and timestamp fields (aligned with StartStep)
- ModeratorContext.signal → ModeratorContext.step
- workflow-utils: start-signal.ts → start-step.ts, isDryRun updated

Fixes #109
2026-04-25 02:34:33 +00:00
xiaoju 47d23bc1a7 Merge pull request 'refactor(store): rename LogEntry.ts → LogEntry.timestamp' (#114) from refactor/113-logentry-timestamp into main 2026-04-25 02:28:38 +00:00
xiaoju 3dc835e1de refactor(store): rename LogEntry/WorkflowRun/ThreadRoundRow ts → timestamp
- Rename logs & workflow_runs table column ts → timestamp (breaking, no migration)
- Update all SQL, types, mocks, CLI output, and tests
- Integration tests use mkdtempSync to avoid stale DB conflicts

Fixes #113
2026-04-25 02:24:39 +00:00
xiaoju 4da2c87a77 refactor(store): rename LogEntry.ts → LogEntry.timestamp
- Rename logs table column ts → timestamp (no migration, breaking)
- Update all SQL, type definitions, and consumers
- Integration tests use mkdtempSync to avoid stale DB conflicts

Fixes #113
2026-04-25 02:08:57 +00:00
xiaoju 529cceba06 Merge pull request 'refactor(core): remove unnecessary | null, unify timestamp naming' (#112) from refactor/108-remove-null-unify-ts into main 2026-04-25 01:57:48 +00:00
xiaoju 020a1bfe85 refactor(core): remove unnecessary | null, unify timestamp naming
- SenseReflexConfig.on: string[] | null → string[] (empty = no conditions)
- NerveConfig.workflows: Record | null → Record (empty = no workflows)
- Signal.ts → Signal.timestamp
- SenseInfo.lastSignalTs → SenseInfo.lastSignalTimestamp
- All consumers across daemon/cli/store updated
- parseNerveConfig: on defaults to [], workflows defaults to {}

Fixes #108
2026-04-25 01:52:58 +00:00
xiaomo 7ce3970027 Merge pull request 'feat(cli): workspace biome.json with noConsole, remove dryRun console.log' (#107) from feat/106-workspace-biome into main 2026-04-25 01:15:53 +00:00
xiaoju fcde29ed1c feat(cli): add biome.json to workspace init, remove dryRun console.log
- init.ts: scaffold biome.json with noConsole: error for workflows
- package.json template: add @biomejs/biome to devDependencies
- workflow-utils: remove console.log from dryRun paths (stub returns
  are captured by log-store via role results)

Fixes #106
2026-04-25 01:00:41 +00:00
xiaomo 611bc48751 Merge pull request 'feat(workflow-utils): dryRun support for spawnSafe, cursorAgent, llmExtract' (#105) from feat/104-dryrun-utils into main 2026-04-25 00:26:27 +00:00
xiaoju 70bea92133 feat(workflow-utils): dryRun support for spawnSafe, cursorAgent, llmExtract
When dryRun=true, each function logs its parameters and returns a stub
result without executing any subprocess or network call. Log output is
captured by log-store for analysis.

- spawnSafe: returns { exitCode: 0, stdout: '[dryRun] skipped' }
- cursorAgent: short-circuits before spawnSafe, returns ok('[dryRun] skipped')
- llmExtract: skips fetch, returns ok({} as T)
- Tests added for spawnSafe and llmExtract dryRun paths

Fixes #104
2026-04-25 00:23:43 +00:00
xiaomo 6f2cddd695 Merge pull request 'feat(core,daemon,cli): add dryRun thread-level parameter to StartSignal' (#103) from feat/101-dry-run into main 2026-04-24 23:50:55 +00:00
xiaoju c4dc707eb0 feat(core,daemon,cli): add dryRun thread-level parameter to StartSignal
- StartSignal.meta gains dryRun: boolean (alongside maxRounds)
- DaemonIpcTriggerWorkflowRequest includes dryRun, parsed with default false
- CLI parses dryRun from --payload JSON, passes through daemon client
- workflow-worker/workflow-manager propagate dryRun through full IPC chain
- Sense-triggered workflows default to dryRun: false
- workflow-utils exports isDryRun(start) helper
- All tests updated, 376 pass

Fixes #101
2026-04-24 23:45:29 +00:00
xiaomo a7ce8401ce Merge pull request 'refactor(core,daemon): extract StartSignal as independent Role parameter' (#102) from refactor/100-extract-start-signal into main 2026-04-24 23:35:09 +00:00
xiaoju e9e6df2f5a refactor(core,daemon): extract StartSignal as independent Role parameter
- Role<Meta> now takes (start: StartSignal, messages: WorkflowMessage[])
- messages no longer contains the __start__ frame
- Add ModeratorContext<M> discriminated union (kind: start | step)
- Moderator receives typed context instead of raw StartSignal | RoleSignal union
- workflow-worker separates start from role messages throughout

Refs #100
2026-04-24 23:14:45 +00:00
xingyue b3b0dad2bb Merge pull request 'feat: add workflow-utils package' (#98) from feat/97-workflow-utils into main 2026-04-24 22:43:07 +00:00
xiaoju e0ce1d995c fix: readNerveYaml returns Result + path traversal guard
Address review feedback:
- Return Result<string, NerveYamlError> instead of throwing
- Add path traversal protection via resolve + startsWith check
- Export NerveYamlError type
- Update sense-generator to handle Result
2026-04-24 22:41:27 +00:00
xiaoju 0a4a2330dc feat: add workflow-utils package
Closes #97
2026-04-24 22:32:29 +00:00
xiaomo d3088c623b Merge pull request 'docs: update all README files to match actual code' (#96) from docs/95-update-readme-to-match-code into main 2026-04-24 21:49:33 +00:00
xiaoju a7e6caf6e7 docs: update all README files to match actual code
Rewrite documentation across all packages to reflect current
architecture, APIs, and CLI commands.

- README.md: fix reflex examples, add store package, update config
- core/README.md: add Sense→workflow routing, IPC types
- daemon/README.md: complete module table, crash recovery, createKernel
- cli/README.md: add workflow/sense/store subcommands
- store/README.md: new file documenting LogStore/BlobStore

Fixes #95
2026-04-24 21:47:37 +00:00
xiaomo d4dcd9722f Merge pull request 'refactor: share IPC message types between CLI and daemon' (#94) from refactor/93-shared-ipc-types into main 2026-04-24 15:14:50 +00:00
xiaoju 3082568b85 refactor(daemon): exhaustive IPC request dispatch
Ensure new DaemonIpcRequest variants require an explicit handler branch.

Made-with: Cursor
2026-04-24 15:11:58 +00:00
xiaoju 830b0aa762 refactor(core): shared daemon IPC request/response types
Move wire protocol types and parseDaemonIpcRequest into @uncaged/nerve-core so CLI and daemon share one definition. Type sendAndReceive message as DaemonIpcRequest. Align workflow trigger CLI with daemon (prompt, maxRounds from --payload JSON).

Made-with: Cursor
2026-04-24 15:10:00 +00:00
xiaoju 777d51cc73 chore: bump version to 0.4.0
小橘 🍊(NEKO Team)
2026-04-24 13:22:30 +00:00
xiaomo 06a957d62a Merge pull request 'chore: add pre-push hook to run tests before push' (#92) from chore/add-pre-push-hook into main 2026-04-24 13:19:10 +00:00
xiaoju b2c379cbfd refactor: reduce cognitive complexity in 3 functions
Extract helpers to bring all functions below biome's complexity threshold (15):
- store/log-store.ts: extract recordToRoundMessage() from parseRoundPayload()
- cli/commands/workflow.ts: extract buildTruncatedSingleRound() from buildThreadCommandOutput()
- daemon/workflow-worker.ts: extract validateRoleResult(), buildInitialLastSignal(),
  initChain(), executeRole() from runThread()

小橘 🍊(NEKO Team)
2026-04-24 12:44:39 +00:00
xiaoju 7cb7112ed6 chore: fix biome lint errors and tune overrides
- Remove duplicate 'prepare' key in package.json
- Allow default exports in rslib.config.ts
- Relax noExplicitAny and noNonNullAssertion in test files
- Auto-fix 17 files (imports, formatting)

小橘 🍊(NEKO Team)
2026-04-24 12:36:57 +00:00
xiaoju 48c81c2e19 chore: add biome lint check to pre-push hook
小橘 🍊(NEKO Team)
2026-04-24 12:32:41 +00:00
xiaoju dd3d4315c4 chore: add pre-push hook to run tests before push
Adds husky with a pre-push hook that runs `pnpm -r test` to catch
test failures before they reach the remote.

小橘 🍊(NEKO Team)
2026-04-24 12:28:47 +00:00
xingyue 788ebc6779 Merge pull request 'fix(test): align tests with type-safety refactor' (#91) from fix/test-failures-after-type-safety-refactor into main 2026-04-24 12:24:50 +00:00
xiaoju 8807b0ac6a fix(test): align tests with type-safety refactor
Update test expectations after workflow reflexes were removed from
YAML config and type signatures were tightened:

- core/config: workflow reflex tests now expect 'not supported' error
- cli/workflow: partitionWorkflowMessage test uses strict typed params
- daemon/crash-recovery: remove triggerPayload from resume-thread assertion
- daemon/daemon-ipc: trigger-workflow sends prompt+maxRounds
- daemon/kernel-workflow: use Sense-driven workflow trigger pattern

Fixes 12 test failures across core, cli, and daemon packages.

Refs #88, #89
2026-04-24 12:23:21 +00:00
xiaomo 5b65afdc4b Merge pull request 'refactor: improve type safety across codebase' (#90) from refactor/type-safety into main 2026-04-24 12:09:36 +00:00
xingyue f5cb72db50 refactor: improve type safety across codebase
- Add isPlainRecord() type guard to eliminate 'as Record<string, unknown>' casts
- Replace 'as any' with properly typed assertions in start.ts
- Remove 'null as unknown as' pattern in kernel.ts
- Add type predicates for array narrowing (item is string)
- Improve IPC message type narrowing in daemon-client.ts and ipc.ts
- Type better-sqlite3 and drizzle return values properly

No runtime behavior changes.
2026-04-24 20:07:58 +08:00
xiaomo e433e7c2a9 Merge pull request 'refactor(daemon): split kernel.ts into focused modules' (#89) from refactor/split-kernel into main 2026-04-24 11:41:44 +00:00
xingyue 47cc49eab4 refactor(daemon): split kernel.ts into focused modules (#86)
- Extract worker-pool.ts (211 LOC): sense worker fork/shutdown/restart/crash recovery
- Extract kernel-file-watch.ts (92 LOC): file change handlers for hot reload
- Extract kernel-sense-groups.ts (29 LOC): group lookup utilities
- kernel.ts reduced from 617 → 380 LOC (thin orchestrator)
- Add worker-pool.test.ts with 8 test cases
- No behavior changes, all existing tests unchanged
2026-04-24 19:39:10 +08:00
xiaomo 65012fbb53 Merge pull request 'refactor(store): extract @uncaged/nerve-store from daemon' (#88) from refactor/extract-nerve-store into main 2026-04-24 11:29:13 +00:00
xingyue 8d00f9cba1 refactor(store): extract @uncaged/nerve-store from daemon (#85)
- Create packages/store/ with log-store, log-archive, blob-store (~900 LOC)
- daemon depends on @uncaged/nerve-store (workspace:*)
- CLI depends on @uncaged/nerve-store, delete daemon-types.ts
- Move store-related tests to packages/store/src/__tests__/
- All store tests pass (73/73), no new regressions
2026-04-24 19:26:46 +08:00
xiaomo ef38b121f7 Merge pull request 'fix: PR #81 review follow-ups (closes #83)' (#84) from fix/pr81-review-followups into main 2026-04-24 11:10:45 +00:00
xiaoju 9bf0b2abb8 fix: PR #81 review follow-ups (closes #83)
- Filter __start__ messages in getThreadRoundCount SQL to fix round offset
- Remove duplicate parseWorkflowField, use parseSenseWorkflowDirective
- Remove unnecessary double casts in workflow CLI
- Add runtime validation for Role meta in workflow-worker
- Export DEFAULT_ENGINE_MAX_ROUNDS from types.ts

小橘 🍊(NEKO Team)
2026-04-24 11:09:31 +00:00
xiaomo d93f5c8fa2 Merge pull request 'refactor(core): restore type-safe workflow automaton from Pulse design' (#81) from refactor/workflow-type-safety into main 2026-04-24 11:02:23 +00:00
xiaoju fa210ec3e0 refactor: restore Pulse-style workflow type safety
- Replace loose payload types with WorkflowLaunchParams { prompt, maxRounds }
- Add SenseResult.workflow field with pipe-separated format (name|rounds|prompt)
- Add parseWorkflowField utility and routeSenseComputeOutput in @nerve/core
- Integrate sense→workflow routing in kernel
- Remove deprecated workflow reflex kind from ReflexScheduler
- Update all test files to use new type-safe interfaces

小橘 🍊(NEKO Team)
2026-04-24 10:58:48 +00:00
xiaoju f72b64d481 refactor(core): restore type-safe workflow automaton from Pulse design (closes #80) 2026-04-24 09:50:28 +00:00
xiaoju b033a98553 chore: bump version to 0.3.0
小橘 <xiaoju@shazhou.work>
2026-04-24 06:08:11 +00:00
xiaomo 68071ffa1e Merge pull request 'feat(cli): add nerve workflow thread <runId> command — closes #77' (#78) from feat/workflow-thread-77 into main 2026-04-24 06:04:32 +00:00
xiaoju f08ad802b0 fix: remove accidentally committed tgz and add to .gitignore
小橘 <xiaoju@shazhou.work>
2026-04-24 06:03:00 +00:00
xiaoju dcfb00128d feat(cli): add nerve workflow thread <runId> command — closes #77
Implements the workflow thread CLI command that retrieves
workflow execution context (logs, events, state) for a given run.

- Add 'nerve workflow thread <runId>' subcommand
- Add log-store query API in daemon
- Add tests for CLI and log-store
- Export new daemon types for thread data

小橘 <xiaoju@shazhou.work>
2026-04-24 05:59:53 +00:00
xiaomo 9cdac05f2c Merge pull request 'docs: add coding agent rules (.cursor/rules + CLAUDE.md)' (#76) from chore/cursor-rules-from-conventions into main 2026-04-23 12:12:08 +00:00
xiaomo 24a8ec927d docs: add core concepts (sense, signal, reflex, workflow) to agent rules 2026-04-23 12:05:31 +00:00
xiaomo 554a79775c docs: add .github/copilot-instructions.md for GitHub Copilot 2026-04-23 12:03:15 +00:00
xiaomo ceb5998fa3 docs: add cursor rules and CLAUDE.md from coding conventions
- .cursor/rules/global.mdc: coding conventions as Cursor agent rules
- CLAUDE.md: same conventions for Claude Code / Hermes agents
- Content derived from docs/coding-conventions.md
- Includes no-dynamic-import rule in CLAUDE.md for completeness
2026-04-23 12:01:03 +00:00
xiaomo 49b5099065 Merge pull request 'fix(daemon): accept string triggerPayload in workflow thread' (#75) from fix/trigger-payload-string-support into main 2026-04-23 11:50:48 +00:00
xiaoju 01d2185495 fix(daemon): accept string triggerPayload in workflow thread
The original code only accepted object-type triggerPayload, silently
discarding string values by replacing them with {}. This meant
`nerve workflow trigger <name> --payload '"some string"'` would
lose the payload entirely.

Changed to `triggerPayload ?? {}` so strings (and other non-null
values) pass through correctly.

小橘 🍊(NEKO Team)
2026-04-23 11:48:05 +00:00
xiaoju 5cedc6a33d release: v0.2.0 — core, daemon, cli 2026-04-23 10:58:49 +00:00
xiaomo c291d3a69a Merge pull request 'feat(cli): add nerve init --from to clone workspace from git' (#74) from feat/init-from-git into main 2026-04-23 10:56:17 +00:00
xiaomo 7960f5af8b Merge pull request 'docs: add comprehensive README for root and all packages' (#73) from docs/readme-update into main 2026-04-23 10:54:43 +00:00
xiaomo 5be14d0d8b docs: add comprehensive README for root and all packages 2026-04-23 10:53:45 +00:00
xiaoju 0e0eb4eec6 feat(cli): add nerve init --from to clone workspace from git
Made-with: Cursor
2026-04-23 10:53:06 +00:00
xiaomo cf2b0ac223 Merge pull request 'build: migrate from tsup to rslib' (#71) from build/tsup-to-rslib into main 2026-04-23 09:50:55 +00:00
xiaoju 1b5a52ea4d build: migrate from tsup to rslib
Replace tsup (esbuild-based) with rslib (rspack-based) across all packages.

tsup's built-in nodeProtocolPlugin strips the 'node:' prefix from all
Node.js builtins. Unlike node:fs etc., node:sqlite has no unprefixed
form, causing ERR_MODULE_NOT_FOUND at runtime. rslib handles node:
imports correctly without any workarounds.

Changes:
- Replace tsup.config.ts with rslib.config.ts in core, daemon, cli
- Swap tsup → @rslib/core in devDependencies
- Fix log-store.ts params type (Record<string, unknown> → Record<string, string | number>)
- Fix logStream.fd type cast in start.ts
- Exclude __tests__ from CLI tsconfig to avoid DTS errors
- All 356 tests pass, nerve init works correctly

Closes #70

小橘 🍊(NEKO Team)
2026-04-23 09:48:45 +00:00
xiaoju a084205b47 Revert "fix: restore node:sqlite prefix stripped by tsup bundler"
This reverts commit 57550ccfdb.
2026-04-23 09:41:28 +00:00
xiaoju 57550ccfdb fix: restore node:sqlite prefix stripped by tsup bundler
tsup's built-in node-protocol-plugin strips the 'node:' prefix from
all builtins. Unlike node:fs etc., node:sqlite has no unprefixed form,
causing ERR_MODULE_NOT_FOUND at runtime.

- Add onSuccess hook to both cli and daemon tsup configs to restore
  'node:sqlite' imports in bundled output
- Fix log-store params type to Record<string, string | number>

小橘 🍊(NEKO Team)
2026-04-23 09:32:20 +00:00
xiaomo 37588df402 Merge pull request 'refactor(daemon): upgrade Drizzle v1.0-beta + migrate better-sqlite3 → node:sqlite' (#69) from refactor/drizzle-v1-node-sqlite into main 2026-04-23 09:20:15 +00:00
xiaoju 85dd11c84d refactor(daemon): upgrade Drizzle v1.0-beta + migrate better-sqlite3 → node:sqlite
- Upgrade drizzle-orm from 0.43.1 to 1.0.0-beta.23
- Replace better-sqlite3 with node:sqlite (DatabaseSync) in:
  - sense-runtime.ts (Drizzle driver)
  - log-store.ts (raw SQL)
  - all test files
- Replace sqlite.pragma() with sqlite.exec('PRAGMA ...')
- Replace sqlite.transaction() with manual BEGIN/COMMIT/ROLLBACK
- Update CLI init command to verify node:sqlite instead of better-sqlite3
- Remove better-sqlite3 and @types/better-sqlite3 from dependencies
- Zero native addons remaining in the monorepo 🎉

Closes #67

小橘 <xiaoju@shazhou.work>
2026-04-23 09:18:44 +00:00
xiaomo d80a414530 Merge pull request 'chore: walkthrough cleanup — engines, types, mock fixes' (#68) from fix/walkthrough-cleanup into main 2026-04-23 09:10:09 +00:00
xiaoju 7f780f0642 chore: walkthrough cleanup — engines, types, mock fixes
- Add engines >= 22.5.0 to root and cli package.json (node:sqlite requirement)
- Remove unused @types/better-sqlite3 from cli devDeps (leftover from sql.js migration)
- Add files/publishConfig to core package.json (parity with other packages)
- Fix daemon test type errors: add getAllWorkflowRuns to mock LogStore,
  fix array destructuring on mock.calls, fix sense-runtime callback signatures

All 356 tests pass across all packages.

小橘 🍊(NEKO Team)
2026-04-23 09:08:24 +00:00
xiaomo 33e0d9a705 Merge pull request 'refactor(cli): replace sql.js with node:sqlite' (#66) from refactor/node-sqlite into main 2026-04-23 08:51:01 +00:00
xiaoju 418d8ee0c8 refactor(cli): replace sql.js with node:sqlite
Drop the sql.js WASM dependency in favour of Node 22's built-in
node:sqlite (DatabaseSync). This eliminates the ~2 MB WASM binary,
removes the async init ceremony, and lets us open databases in
readonly mode directly on disk instead of loading them into memory.

Breaking: requires Node >= 22.5.0 (sqlite support).

- Remove sql.js from cli dependencies
- Rewrite sense-sqlite.ts to use DatabaseSync
- Update sense command (schema/query) — sync API, no more queryAsObjects
- Update tests to use node:sqlite directly
- Remove sql.js from tsup externals

小橘 🍊(NEKO Team)
2026-04-23 08:43:39 +00:00
xiaomo 719c4c1449 Merge pull request 'refactor(cli): replace better-sqlite3 with sql.js (pure WASM) — implements RFC #63' (#64) from refactor/sql-js-migration into main 2026-04-23 07:32:38 +00:00
xiaoju c8bf4bf547 refactor(cli): replace better-sqlite3 with sql.js (pure WASM)
- Remove native C++ addon dependency, no more pnpm approve-builds
- sql.js loads SQLite as WASM, zero compilation required
- WASM init is singleton (once per process)
- Add queryAsObjects() adapter for sql.js columnar → row format
- Tests migrated to sql.js (16 passing)

Implements RFC #63
2026-04-23 07:25:08 +00:00
xiaoju 9b93c4a4d9 chore(cli): bump version to 0.1.8 2026-04-23 07:10:28 +00:00
xiaomo ca14c5f51d Merge pull request 'feat(cli): add nerve sense schema and query commands (closes #60)' (#62) from feat/sense-query into main 2026-04-23 07:06:02 +00:00
xiaomo 1979e0e16c Merge pull request 'refactor: replace dynamic imports with static imports in CLI' (#61) from refactor/static-imports into main 2026-04-23 07:04:31 +00:00
xingyue 9102c6698a chore: remove gitea-access rule from project (belongs in agent local skills) 2026-04-23 15:03:14 +08:00
xiaoju b15fc993f2 feat(cli): add nerve sense schema and query commands
Open each sense SQLite file read-only under data/senses. schema lists CREATE TABLE SQL from sqlite_master; query runs optional SQL or a default SELECT ordered by rowid. Human output uses aligned columns; --json for machine-readable output. Add better-sqlite3 to the CLI package and externalize it in tsup.

Tests cover sense-sqlite helpers and integration against a temp database.

Made-with: Cursor
2026-04-23 07:01:16 +00:00
xingyue 6cc8833b2a chore: add cursor rules and annotate legitimate dynamic imports
- Add .cursor/rules/no-dynamic-import.mdc: ban dynamic import() in
  production code with documented exceptions
- Add .cursor/rules/gitea-access.mdc: tea CLI usage guide
- Add explanatory comments on the 2 legitimate dynamic imports in
  sense-runtime.ts and workflow-worker.ts
2026-04-23 15:00:07 +08:00
xiaomo fc76b862ad Merge pull request 'refactor(cli): replace dynamic imports with static imports — closes #57' (#59) from refactor/static-imports into main 2026-04-23 06:55:46 +00:00
xingyue 787e791aba refactor(cli): replace dynamic imports with static imports
Convert 6 unnecessary `await import()` calls for Node built-in modules
(node:child_process, node:util) and project modules (../workspace.js)
to static top-level imports in init.ts and start.ts.

Closes #57
2026-04-23 14:52:18 +08:00
xiaomo 96188c8cda Merge pull request 'fix(daemon): foreground worker signals and crash diagnostics (closes #55, closes #56)' (#58) from fix/dev-worker-crash into main 2026-04-23 06:48:33 +00:00
xiaoju f1458f8353 fix(daemon): foreground worker signals and crash diagnostics
Ignore SIGINT/SIGTERM only when fork IPC is active (process.send) so terminal signals do not race the kernel shutdown in nerve dev, without breaking standalone worker CLIs (fixes #55).

Pipe worker stderr through the parent with a rolling capture buffer; log exit signal name and stderr tail on worker exit (fixes #56). Apply the same exit logging to workflow workers.

Made-with: Cursor
2026-04-23 06:41:32 +00:00
xiaomo 781f571474 Merge pull request 'refactor: add daemon subcommand group and dev foreground mode' (#54) from refactor/daemon-subcommand into main 2026-04-23 04:24:31 +00:00
xiaoju 640f170de8 refactor: add daemon subcommand group and dev foreground mode
- Create 'nerve daemon' subcommand group: start, stop, status, restart, logs
- Create 'nerve dev' for foreground mode (replaces old start without -d)
- 'nerve daemon start' is always background (removed -d/--daemon flag)
- Keep top-level aliases: nerve start/stop/status/logs → nerve daemon *
- Extract runStopCommand() for restart reuse
- Add daemon-cli tests

Closes #53

小橘 🍊(NEKO Team)
2026-04-23 01:16:13 +00:00
xiaoju 119b1f3722 chore: enforce pnpm publish for all packages unconditionally
小橘 <xiaoju@shazhou.work>
2026-04-23 00:49:39 +00:00
xiaoju 96ea4b46ff chore: add prepublish guard against npm publish with workspace:* deps
小橘 <xiaoju@shazhou.work>
2026-04-23 00:47:56 +00:00
xiaoju 57881533a8 docs: fix publish skill — use pnpm publish for workspace:* conversion
小橘 <xiaoju@shazhou.work>
2026-04-23 00:43:51 +00:00
xiaoju a62a993a82 fix(cli): remove duplicate shebang in daemon-bootstrap causing crash on nerve start -d
小橘 <xiaoju@shazhou.work>
2026-04-23 00:43:18 +00:00
xiaoju 3f22eb4664 release: @uncaged/nerve-core@0.1.3, @uncaged/nerve-daemon@0.1.4, @uncaged/nerve-cli@0.1.5
小橘 <xiaoju@shazhou.work>
2026-04-23 00:35:40 +00:00
xiaoju b5913263e4 docs: add publish and setup skills
小橘 <xiaoju@shazhou.work>
2026-04-23 00:31:27 +00:00
xiaomo d3ecd2a492 Merge pull request 'fix: address review issues #46-#49' (#52) from fix/review-issues-46-49 into main 2026-04-23 00:24:19 +00:00
xiaoju 8763440436 fix: address review issues #46-#49
#46 — EPIPE handler: only silence EPIPE, log other child errors
#47 — lastSignalTs: query sense/signal instead of reflex/run_complete
#48 — SenseInfo: deduplicate to @uncaged/nerve-core, add expectTypeOf test
#49 — IPC client: extract sendAndReceive<T> to eliminate duplication

小橘 <xiaoju@shazhou.work>
2026-04-23 00:22:55 +00:00
xiaomo f270804002 Merge pull request 'feat(daemon): CAS blob store — sha256 content-addressable storage (closes #39)' (#51) from feat/blob-store into main 2026-04-23 00:21:46 +00:00
xiaoju 404ee3e34f feat(daemon): add CAS blob store with sha256 content-addressable storage — closes #39
- createBlobStore(root) with write/read/exists API
- sha256 hex, first 2 chars as shard directory
- Atomic writes via temp file + rename
- CAS mismatch detection on read and write
- Inject blobStore into sense compute via options.blobs
- Export createBlobStore, normalizeBlobHash, BlobStore type
2026-04-23 00:19:35 +00:00
xiaomo cbc6db6b7d Merge pull request 'feat(daemon): log store archival — Meta table + JSONL cold archive (closes #38)' (#45) from feat/log-archive into main 2026-04-23 00:17:54 +00:00
xiaomo b1f6c775ce Merge pull request 'fix(init): auto-verify and retry better-sqlite3 native build — closes #44' (#50) from fix/init-sqlite-retry into main 2026-04-23 00:14:30 +00:00
xingyue 4ada5ef335 fix(init): auto-verify and retry better-sqlite3 native build
After pnpm install, verify better-sqlite3 actually loads by spawning
a test process. If it fails, rebuild up to 2 times. On final failure,
print actionable fix commands instead of a vague warning.

Closes #44
2026-04-23 08:12:10 +08:00
xiaoju 978b1680a3 feat(daemon): add log store archival with meta watermark + JSONL cold archive — closes #38
- Add meta table with archived_up_to watermark in logs.db
- Archive logs older than 30 days to data/archive/logs/YYYY-MM-DD.jsonl
- Idempotent: same-day re-export overwrites file
- Single transaction: DELETE + UPDATE meta
- Optional VACUUM after archive loop
- CLI: nerve store archive [--vacuum]
- 15+ new tests for archive logic
2026-04-23 00:10:20 +00:00
xiaoju ac34b798c2 feat(cli): add nerve sense list command with IPC + static fallback — closes #37
- daemon-ipc: add list-senses request type returning SenseInfo[]
- kernel: implement listSenses querying logStore for last signal time
- CLI: nerve sense list with table output, fallback to nerve.yaml when daemon is down
- 25 new tests across daemon-ipc and CLI
2026-04-23 00:00:23 +00:00
xiaoju 00c9b7e406 test: add trigger-sense unit + integration tests — closes #36
- daemon-ipc: parse trigger-sense request, success/failure responses
- kernel: triggerSense routing to correct worker, unknown sense error
- CLI: triggerSenseViaDaemon IPC round-trip
2026-04-22 23:53:23 +00:00
xiaoju 8b216e3f01 Revert "feat(cli): add nerve init sense <name> scaffold command — closes #36"
This reverts commit 7ded3a758a.
2026-04-22 23:44:18 +00:00
xiaoju 7ded3a758a feat(cli): add nerve init sense <name> scaffold command — closes #36
Implements nerve init sense <name> command that scaffolds a new sense directory under ~/.uncaged-nerve/senses/<name>/ with schema.ts, index.js, and migrations/0001_init.sql. Also auto-patches nerve.yaml to add the sense config and reflex entry. Includes full test coverage for all exported helpers.

Made-with: Cursor
2026-04-22 23:43:30 +00:00
xiaoju 3257237ba7 fix: handle EPIPE on child process IPC during shutdown
Add error event listener to forked workers in kernel and
workflow-manager to prevent unhandled EPIPE crashes.

Closes #43
小橘 <xiaoju@shazhou.work>
2026-04-22 23:36:48 +00:00
xiaoju 2be11ac81a chore: release core@0.1.2 daemon@0.1.2 cli@0.1.3
小橘 <xiaoju@shazhou.work>
2026-04-22 23:12:29 +00:00
xiaomo 5ed4dfdde3 Merge pull request 'refactor(cli): decouple daemon native deps from CLI global install — closes #41' (#42) from refactor/decouple-daemon-from-cli into main 2026-04-22 23:09:56 +00:00
xingyue 282a802f06 fix: address review feedback on PR #42
1. [BLOCKER] tsup.config.ts: resolve merge conflict — keep both banner
   (shebang) and external (daemon decoupling)

2. [SHOULD-FIX] assertWorkspaceDaemonInstalled: throw Error instead of
   process.exit(1) — callers decide error handling

3. [SHOULD-FIX] getDaemonEntryPath: read daemon's package.json 'main'
   field instead of hardcoding dist/index.js

4. [SHOULD-FIX] daemon startup check: replace sleep(1500) with IPC
   socket polling (200ms intervals, 5s timeout)

5. [SHOULD-FIX] daemon-types drift: add vitest type-level assertions
   that verify CLI mirror types stay assignable with daemon exports
2026-04-23 07:07:38 +08:00
xingyue c8e6409837 refactor(cli): decouple daemon native deps from CLI global install
- Move @uncaged/nerve-daemon from runtime to devDependencies
- Dynamic import daemon from workspace node_modules at runtime
- Add daemon-bootstrap.ts as separate entry for background daemon spawn
- Extract run-foreground-kernel.ts and workspace-daemon.ts modules
- Add daemon-types.ts for structural types (no runtime daemon import)
- Rebuild better-sqlite3 in workspace during nerve init
- Validate daemon process liveness after spawn in background mode
- Mark @uncaged/nerve-daemon as external in tsup config

Closes #41
2026-04-23 06:58:00 +08:00
xiaoju 877da470d7 fix: pre-approve build scripts in nerve init scaffold
Add pnpm.onlyBuiltDependencies for better-sqlite3 and esbuild
to suppress pnpm v10 approve-builds warnings.

小橘 <xiaoju@shazhou.work>
2026-04-22 15:49:13 +00:00
xiaoju 01f54d14c5 chore: bump to 0.1.1 for npm publish fix
小橘 <xiaoju@shazhou.work>
2026-04-22 15:37:30 +00:00
xiaoju 6a689c4094 feat: make nerve-cli and nerve-daemon publishable npm packages
- Remove private:true from cli and daemon package.json
- Add files and publishConfig fields
- Add shebang banner via tsup for CLI entry
- Add trigger-sense IPC support in daemon and client

Closes #40

小橘 <xiaoju@shazhou.work>
2026-04-22 15:28:05 +00:00
xiaomo e66a376a77 Merge pull request 'feat: add nerve logs command with AI-friendly pagination — closes #29' (#34) from feat/nerve-logs into main 2026-04-22 15:04:52 +00:00
xiaoju 10f942b577 fix: address PR #34 review — SIGINT leak, negative offset, follow race conditions
- SIGINT: use process.once instead of process.on
- Negative offset: validate and exit(1) with error to stderr
- Follow mode: sequential while loop replaces setInterval (no async race)
- Log rotation: reset size when newSize < size
- TODO: readAllLines large file optimization note
- 2 new tests for negative offset validation

小橘 <xiaoju@shazhou.work>
2026-04-22 15:00:24 +00:00
xiaoju 76b547d37a feat: add nerve logs command with AI-friendly pagination — closes #29
- nerve logs: tail last 50 lines by default
- -n <lines>: specify line count
- --offset <n>: pagination from line n (1-based)
- -f/--follow: real-time tail with 300ms polling
- Footer with stats + next-page command hint for AI agents
- No ANSI colors, emoji only, data→stdout, errors→stderr
- 19 new tests covering pagination, footer, edge cases

小橘 <xiaoju@shazhou.work>
2026-04-22 14:52:17 +00:00
xiaoju 1b2ff37097 chore: publish @uncaged/nerve-core@0.0.1 to npm — closes #28
Removed 'private: true' to allow npm publish. Package is now available
at https://www.npmjs.com/package/@uncaged/nerve-core

小橘 <xiaoju@shazhou.work>
2026-04-22 14:37:07 +00:00
xiaoju 4add0d88c6 Revert "Merge pull request 'fix: remove unpublished @uncaged/nerve-core from init template — closes #28' (#33) from fix/remove-unpublished-dep into main"
This reverts commit a8404dc096, reversing
changes made to 569c034b49.
2026-04-22 14:36:24 +00:00
xiaoju a8404dc096 Merge pull request 'fix: remove unpublished @uncaged/nerve-core from init template — closes #28' (#33) from fix/remove-unpublished-dep into main 2026-04-22 14:35:24 +00:00
xiaoju 891db36152 fix: remove unpublished @uncaged/nerve-core from init template — closes #28
The workspace package.json template listed @uncaged/nerve-core as a
dependency, but this package is not published to npm. Since the generated
workflow code only imports from @uncaged/nerve-daemon (which is also not
yet published but will be), remove the unnecessary dependency to unblock
`nerve init`.

小橘 <xiaoju@shazhou.work>
2026-04-22 14:35:03 +00:00
xiaoju 569c034b49 Merge pull request 'fix: daemon mode spawn path — closes #27' (#30) from fix/daemon-spawn-path into main 2026-04-22 14:21:33 +00:00
xingyue 85fa282d2e fix(cli): create initial git commit after workspace init
git init without add+commit leaves the workspace in a dirty state
with no baseline to diff against.
2026-04-22 22:16:41 +08:00
xiaomo b75a112c95 Merge pull request 'fix: IPC trigger try/catch + test import cleanup' (#32) from fix/phase4-followup into main 2026-04-22 14:16:10 +00:00
xingyue 606eff6d70 fix(cli): remove self-fallback in cliEntryScript candidates
Per review: third candidate (here) is wrong — if bundled and source
candidates both miss, falling back to self reproduces the original bug.
Keep only the two valid candidates and throw on miss.
2026-04-22 22:15:53 +08:00
xingyue 97305bd9af fix(cli): resolve CLI entry path for bundled dist output
cliEntryScript() assumed source directory structure (src/commands/start.ts → ../cli.ts),
but after tsup bundles everything into dist/cli.js, import.meta.url points to dist/cli.js
and the '../cli.js' path resolves to a non-existent file.

Use candidate-based lookup: try same-dir, parent-dir, then self (bundled case).
2026-04-22 22:15:53 +08:00
xingyue 3f2c9df75d refactor: simplify cliEntryScript() — remove multi-level fallback
Per review feedback from xiaoju: the three-level fallback was over-defensive.
Since start.ts and cli.ts have a fixed relative position (commands/start.ts → ../cli.ts),
we can derive the path directly from import.meta.url with an existsSync guard.

This makes path errors explicit (throw) instead of silently falling back to
a potentially wrong path.
2026-04-22 22:15:53 +08:00
xingyue 1511cfd595 fix: daemon spawn uses CLI entry path instead of command module
The runDaemon function was using import.meta.url (pointing to start.js)
as the script for the spawned child process. This meant the child ran
`node start.js start` which has no CLI entry logic and exits immediately.

Added cliEntryScript() that resolves to the correct CLI entry (cli.js)
regardless of whether the code is bundled or split into separate files.

Closes #27
2026-04-22 22:15:53 +08:00
xiaoju 362dc94582 fix: add try/catch to IPC trigger handler & import real buildWorkflowTemplate in test
- daemon-ipc: wrap startWorkflow() in try/catch so errors are sent back
  as {ok:false, error:msg} instead of silently dropping the socket
- init-workflow.test: import buildWorkflowTemplate from init.ts instead
  of maintaining an inline copy

Addresses review follow-up suggestions from PR #31.

小橘 <xiaoju@shazhou.work>
2026-04-22 14:15:19 +00:00
xiaomo 9e7de3b4e0 Merge pull request 'feat: Workflow Engine Phase 4 — CLI & User Experience' (#31) from feat/workflow-engine-phase4 into main 2026-04-22 14:12:14 +00:00
xiaoju 7320761277 fix(cli): address PR #31 review — 6 issues fixed
Critical:
1. trigger: use Unix socket IPC to daemon instead of direct DB write
   - new daemon-ipc.ts (server) + daemon-client.ts (client)
   - kernel accepts ipcSocketPath, auto-starts IPC server
2. init workflow: validate name (lowercase alphanumeric + hyphens only)

Should fix:
3. getAllWorkflowRuns: SQL query on workflow_runs table instead of O(n) scan
4. limit/offset: robust parseIntArg() helper with NaN handling
5. statusIcon: exhaustive switch with never type check
6. trigger: end-to-end Unix socket tests added

12 new tests. All 224 tests pass.

小橘 🍊(NEKO Team)
2026-04-22 14:10:43 +00:00
xiaoju 262c77175f feat(cli): Phase 4 — workflow CLI commands & scaffold
- workflow list: active runs with --all/--workflow/--limit/--offset pagination
- workflow inspect <runId>: thread details with event pagination
- workflow trigger <name>: manual trigger, outputs runId
- init workflow <name>: scaffold template under workflows/

AI-friendly design: no ANSI colors, emoji for readability, pagination
with stats + next-page hint on all list commands.

47 new tests (39 workflow + 8 init-workflow). All 212 tests pass.

小橘 🍊(NEKO Team)
2026-04-22 13:46:05 +00:00
xiaomo ae80aef6b4 Merge pull request 'feat: Workflow Engine Phase 3 — Crash Recovery, Hot Reload & Incremental Config' (#22) from feat/workflow-engine-phase3 into main 2026-04-22 13:26:29 +00:00
xiaoju 8d92928951 fix(daemon): address PR #22 review — 6 issues fixed
Critical:
1. replayAndResume: remove double moderate() call, reuse loop result
2. drainAndRespawn: check workflow still in config before respawn
3. drain: mark in-flight runs as 'interrupted' in DB before clearing

Should fix:
4. crash recovery: dedup runId before re-queuing/re-activating
5. drain timeout: DEFAULT_DRAIN_TIMEOUT_MS > WORKER_SHUTDOWN_TIMEOUT_MS
6. crash-loop protection: max 5 crashes in 60s window, then stop respawn

5 new tests added. All 173 tests pass.

小橘 🍊(NEKO Team)
2026-04-22 13:25:35 +00:00
xiaoju 49ed65a330 feat(daemon): Phase 3 — crash recovery, hot reload & incremental config
- workflow-manager: crash detection, worker respawn, thread resume from
  persisted events, drainAndRespawn() for hot reload
- log-store: getTriggerPayload(), getThreadEvents() for crash recovery
- file-watcher: detect workflow .ts file changes under workflows/
- kernel: handleWorkflowFileChange(), incremental workflow config updates
  on reloadConfig() (add/remove/update concurrency)
- ipc: resume-thread message type for crash recovery
- workflow-worker: handle resume-thread, rebuild ThreadState from events

28 new tests across 4 test files. All 168 tests pass.

小橘 🍊(NEKO Team)
2026-04-22 13:25:35 +00:00
xiaomo b7dfe42a96 Merge pull request 'fix: init runtime bugs - missing dir, .ts/.js mismatch, TS annotations' (#26) from fix/init-runtime-bugs into main 2026-04-22 13:22:52 +00:00
xingyue a887fc04ca fix: init creates data/senses dir, generates .js templates without TS annotations
- Add mkdirSync for data/senses/ in init command (#23)
- Add defensive mkdirSync in sense-runtime before DB open (#23)
- Change init template output from index.ts to index.js (#24)
- Remove TypeScript type annotations from CPU usage template (#25)

Closes #23, closes #24, closes #25
2026-04-22 21:15:42 +08:00
xiaomo d5931a9e19 Merge pull request 'feat: Workflow Engine Phase 2 — Kernel Integration' (#21) from feat/workflow-engine-phase2 into main 2026-04-22 12:45:43 +00:00
xiaoju f12f187d8d feat(daemon): Phase 2 — kernel ↔ workflow integration
- kernel.ts: create WorkflowManager, expose on Kernel, wire into
  ReflexScheduler via workflowTriggerFn, add activeWorkflows to
  KernelHealth, graceful shutdown ordering
- reflex-scheduler.ts: handle kind:'workflow' reflexes — subscribe to
  bus signals and delegate to workflowTriggerFn
- workflow-manager.ts: add totalActiveCount(), updateConfig() for hot
  reload support

All 140 tests pass.

小橘 🍊(NEKO Team)
2026-04-22 12:40:57 +00:00
xiaomo 1512113a01 Merge pull request 'feat: Workflow Engine Phase 1' (#17) from feat/workflow-engine-phase1 into main 2026-04-22 12:20:12 +00:00
xiaoju 8f495cf92e fix: address all 8 PR #17 review issues
Critical fixes:
1. triggerPayload spread → safe field assignment with validation
2. Graceful shutdown stopping flag → no false crash warnings
3. Shutdown awaits in-flight work (10s timeout) before exit
4. Thread loop safety valve (MAX_STEPS=1000)
5. active counter: bare number → Set<string> by runId

Should-fix:
6. ThreadEventMessage.eventType → union type with validation
7. SQLite status cast → runtime validateWorkflowRunStatus()
8. getActiveWorkflowRuns → pre-compiled prepared statements

小橘 <xiaoju@shazhou.work>
2026-04-22 12:13:29 +00:00
xiaoju a68338c4e9 feat: implement Workflow Engine Phase 1 (#16)
- Core types: WorkflowDefinition, ThreadState, CommandEvent, ModerateResult, etc.
- IPC: StartThreadMessage, ResumeThreadMessage, ThreadEventMessage, WorkflowErrorMessage
- WorkflowManager: concurrency control (drop/queue overflow), worker lifecycle
- Workflow worker: moderate→execute loop, user code loading
- LogStore: workflow_runs materialized table, appendWithWorkflowUpdate
- 131 tests passing

RFC-002 Phase 1 complete.

小橘 <xiaoju@shazhou.work>
2026-04-22 11:49:50 +00:00
xiaoju 9802f68380 docs: add RFC-002 Workflow Engine
Defines the workflow execution engine design:
- Thread lifecycle with event sourcing
- Concurrency control (drop/queue overflow)
- WorkflowManager, workflow worker process model
- IPC extension, crash recovery, hot reload
- 4-phase implementation plan

小橘 <xiaoju@shazhou.work>
2026-04-22 11:26:28 +00:00
xiaomo f245224320 Merge pull request 'Phase 7: Structured Logging System' (#15) from feat/phase-7-logging into main 2026-04-22 11:21:47 +00:00
xiaoju 1b995379f9 feat: Phase 7 — structured logging system (RFC §2.4/§5.4)
- log-store.ts: SQLite append-only log store with query API and meta table
- kernel: system start/stop logs, signal/error logging, file watcher events
- reflex-scheduler: run_start/run_complete logging per compute
- Exports: createLogStore, LogStore, LogEntry, LogQuery
- Tests: log-store CRUD, query filters, meta, integration with reflex

Closes #14
小橘 🍊(NEKO Team)
2026-04-22 11:20:29 +00:00
xiaomo ea6bc5610b Merge pull request 'Phase 6: Hot Reload & Error Handling' (#13) from feat/phase-6-hot-reload into main 2026-04-22 11:12:38 +00:00
xiaoju 49d078b144 fix: address PR #13 review — per-sense timeout, reload restart, await ready
- reloadConfig: restart existing groups when new senses added
- sense-worker: per-sense timeout/gracePeriod lookup at compute time (RFC §5.3)
- restartGroup: await worker ready promise before returning
- Comments: scheduler in-flight state loss, fs.watch Linux caveats, grace period blast radius

小橘 🍊(NEKO Team)
2026-04-22 11:07:33 +00:00
xiaoju 9ca8c8ecb8 feat: Phase 6 — hot reload, error isolation, grace period, nerve-health
- file-watcher.ts: watch nerveRoot for .ts and nerve.yaml changes
- kernel.ts: restartGroup(), reloadConfig(), getHealth(), auto-respawn on crash
- sense-worker.ts: compute try/catch error isolation, grace_period hard kill
- ipc.ts: new message types for health, restart, reload
- examples/senses/nerve-health.ts: built-in daemon health sense
- Integration tests for hot reload, error isolation, grace period

小橘 🍊(NEKO Team)
2026-04-22 10:57:00 +00:00
xiaomo 00c1932144 Merge pull request 'feat: Phase 5 — CLI & User Workspace' (#12) from feat/phase-5-cli-workspace into main 2026-04-22 10:33:53 +00:00
129 changed files with 16632 additions and 1394 deletions
+189
View File
@@ -0,0 +1,189 @@
---
description: Nerve project coding conventions — style, patterns, and toolchain
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# Nerve Coding Conventions
## Core Concepts
```
External World → Sense → Signal → Reflex → Workflow → Log
↑ ↑
"what to observe" "what to do"
```
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
### Key Terms
| Concept | What it is |
|---------|-----------|
| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
### Architecture Rules
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
## Language & Paradigm
### Functional-first
Use `function` + `type`, not `class` + `interface`.
```typescript
// ✅ Good
type Signal = {
senseId: string;
value: unknown;
ts: number;
};
function createSignal(senseId: string, value: unknown): Signal {
return { senseId, value, ts: Date.now() };
}
// ❌ Bad — no class, no interface
class Signal implements ISignal { ... }
```
### Rules
| Rule | Description |
|------|-------------|
| `type` over `interface` | All type definitions use `type` |
| `function` over `class` | Pure functions + closures, no class |
| No `this` | Functions must not depend on `this` context |
| No inheritance | No `extends`, `implements`, `abstract` |
| Composition over inheritance | Use function composition |
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
| No optional properties | Use `T \| null` instead of `?:` — see below |
### Exceptions
Classes are allowed when:
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
- Error subclasses (`class NerveError extends Error`)
### No Optional Properties
Never use `?:`. All nullable fields must be explicit `T | null`.
```typescript
// ✅ Good
type SenseConfig = {
group: string;
throttle: string | null;
timeout: string | null;
};
// ❌ Bad
type SenseConfig = {
group: string;
throttle?: string;
timeout?: string;
};
```
For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good
type ReflexConfig =
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
| { kind: "workflow"; workflow: string; on: string[] | null };
```
## Modules & Exports
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
```typescript
// ✅ Named exports only
export function startEngine(config: EngineConfig): Engine { ... }
export type EngineConfig = { ... };
// ❌ No default exports
export default function startEngine() { ... }
```
## Naming
| Type | Style | Example |
|------|-------|---------|
| Files | kebab-case | `signal-bus.ts` |
| Types | PascalCase | `SignalBus` |
| Functions/variables | camelCase | `createSignalBus` |
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
| Generics | Single letter or descriptive | `T`, `TValue` |
## Error Handling
- Use `Result` type for expected failures
- `throw` only for unrecoverable bugs (programmer errors)
- No try-catch for flow control
```typescript
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function parseSenseConfig(raw: unknown): Result<SenseConfig> { ... }
```
## Async
- Always `async/await`, never `.then()` chains
## Toolchain
| Tool | Purpose |
|------|---------|
| **pnpm** | Package manager |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format (replaces ESLint + Prettier) |
| **tsup** | Bundling |
### Commands
```bash
pnpm run check # biome check (lint + format)
pnpm run format # biome format --write
pnpm run build # full build
pnpm test # run tests
```
## Monorepo Structure
```
nerve/
packages/
core/ # @nerve/core — shared types and utils
cli/ # @nerve/cli — CLI entry point
daemon/ # @nerve/daemon — engine runtime
docs/ # RFCs, conventions
biome.json # root Biome config
tsconfig.json # root TypeScript config (composite project references)
```
- `core` is the shared layer; `cli` and `daemon` both depend on it
- `cli` and `daemon` must NOT depend on each other
## Commit Convention
```
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: core | cli | daemon | rfc-001 | ...
```
+34
View File
@@ -0,0 +1,34 @@
---
description: Ban dynamic import() in production code — use static imports instead
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# No Dynamic Import in Production Code
## Rule
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
Always use static top-level `import` statements.
## Why
- Static imports enable tree-shaking and bundler optimizations
- They make dependencies explicit and discoverable at a glance
- Dynamic imports of Node built-ins or project modules add unnecessary async overhead
## Exceptions (must include a comment explaining why)
1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime
2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
When suppressing, add a comment directly above:
```ts
// Dynamic import required: user module path resolved at runtime
const mod = await import(senseIndexPath);
```
## Test Files
Test files (`__tests__/**`) are exempt — dynamic import after `vi.mock()` is standard vitest practice.
+180
View File
@@ -0,0 +1,180 @@
# Nerve Coding Conventions
## Core Concepts
```
External World → Sense → Signal → Reflex → Workflow → Log
↑ ↑
"what to observe" "what to do"
```
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
### Key Terms
| Concept | What it is |
|---------|-----------|
| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
### Architecture Rules
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
## Language & Paradigm
### Functional-first
Use `function` + `type`, not `class` + `interface`.
```typescript
// ✅ Good
type Signal = {
senseId: string;
value: unknown;
ts: number;
};
function createSignal(senseId: string, value: unknown): Signal {
return { senseId, value, ts: Date.now() };
}
// ❌ Bad — no class, no interface
class Signal implements ISignal { ... }
```
### Rules
| Rule | Description |
|------|-------------|
| `type` over `interface` | All type definitions use `type` |
| `function` over `class` | Pure functions + closures, no class |
| No `this` | Functions must not depend on `this` context |
| No inheritance | No `extends`, `implements`, `abstract` |
| Composition over inheritance | Use function composition |
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
| No optional properties | Use `T \| null` instead of `?:` — see below |
### Exceptions
Classes are allowed when:
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
- Error subclasses (`class NerveError extends Error`)
### No Optional Properties
Never use `?:`. All nullable fields must be explicit `T | null`.
```typescript
// ✅ Good
type SenseConfig = {
group: string;
throttle: string | null;
timeout: string | null;
};
// ❌ Bad
type SenseConfig = {
group: string;
throttle?: string;
timeout?: string;
};
```
For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good
type ReflexConfig =
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
| { kind: "workflow"; workflow: string; on: string[] | null };
```
## Modules & Exports
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
## Naming
| Type | Style | Example |
|------|-------|---------|
| Files | kebab-case | `signal-bus.ts` |
| Types | PascalCase | `SignalBus` |
| Functions/variables | camelCase | `createSignalBus` |
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
| Generics | Single letter or descriptive | `T`, `TValue` |
## Error Handling
- Use `Result` type for expected failures
- `throw` only for unrecoverable bugs (programmer errors)
- No try-catch for flow control
```typescript
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```
## Async
- Always `async/await`, never `.then()` chains
## No Dynamic Import
Do NOT use `await import()` in production code. Always use static top-level `import`.
Exceptions (must include a comment):
1. `sense-runtime.ts` — user module paths known only at runtime
2. `workflow-worker.ts` — user module paths known only at runtime
Test files (`__tests__/**`) are exempt.
## Toolchain
| Tool | Purpose |
|------|---------|
| **pnpm** | Package manager |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format (replaces ESLint + Prettier) |
| **tsup** | Bundling |
### Commands
```bash
pnpm run check # biome check (lint + format)
pnpm run format # biome format --write
pnpm run build # full build
pnpm test # run tests
```
## Monorepo Structure
```
nerve/
packages/
core/ # @nerve/core — shared types and utils
cli/ # @nerve/cli — CLI entry point
daemon/ # @nerve/daemon — engine runtime
docs/ # RFCs, conventions
```
- `core` is the shared layer; `cli` and `daemon` both depend on it
- `cli` and `daemon` must NOT depend on each other
## Commit Convention
```
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: core | cli | daemon | rfc-001 | ...
```
+1
View File
@@ -2,3 +2,4 @@ node_modules
dist
.turbo
*.tsbuildinfo
*.tgz
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
pnpm check
pnpm -r test
+180
View File
@@ -0,0 +1,180 @@
# Nerve Coding Conventions
## Core Concepts
```
External World → Sense → Signal → Reflex → Workflow → Log
↑ ↑
"what to observe" "what to do"
```
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
### Key Terms
| Concept | What it is |
|---------|-----------|
| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
### Architecture Rules
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
## Language & Paradigm
### Functional-first
Use `function` + `type`, not `class` + `interface`.
```typescript
// ✅ Good
type Signal = {
senseId: string;
value: unknown;
ts: number;
};
function createSignal(senseId: string, value: unknown): Signal {
return { senseId, value, ts: Date.now() };
}
// ❌ Bad — no class, no interface
class Signal implements ISignal { ... }
```
### Rules
| Rule | Description |
|------|-------------|
| `type` over `interface` | All type definitions use `type` |
| `function` over `class` | Pure functions + closures, no class |
| No `this` | Functions must not depend on `this` context |
| No inheritance | No `extends`, `implements`, `abstract` |
| Composition over inheritance | Use function composition |
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
| No optional properties | Use `T \| null` instead of `?:` — see below |
### Exceptions
Classes are allowed when:
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
- Error subclasses (`class NerveError extends Error`)
### No Optional Properties
Never use `?:`. All nullable fields must be explicit `T | null`.
```typescript
// ✅ Good
type SenseConfig = {
group: string;
throttle: string | null;
timeout: string | null;
};
// ❌ Bad
type SenseConfig = {
group: string;
throttle?: string;
timeout?: string;
};
```
For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good
type ReflexConfig =
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
| { kind: "workflow"; workflow: string; on: string[] | null };
```
## Modules & Exports
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
## Naming
| Type | Style | Example |
|------|-------|---------|
| Files | kebab-case | `signal-bus.ts` |
| Types | PascalCase | `SignalBus` |
| Functions/variables | camelCase | `createSignalBus` |
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
| Generics | Single letter or descriptive | `T`, `TValue` |
## Error Handling
- Use `Result` type for expected failures
- `throw` only for unrecoverable bugs (programmer errors)
- No try-catch for flow control
```typescript
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```
## Async
- Always `async/await`, never `.then()` chains
## No Dynamic Import
Do NOT use `await import()` in production code. Always use static top-level `import`.
Exceptions (must include a comment):
1. `sense-runtime.ts` — user module paths known only at runtime
2. `workflow-worker.ts` — user module paths known only at runtime
Test files (`__tests__/**`) are exempt.
## Toolchain
| Tool | Purpose |
|------|---------|
| **pnpm** | Package manager |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format (replaces ESLint + Prettier) |
| **tsup** | Bundling |
### Commands
```bash
pnpm run check # biome check (lint + format)
pnpm run format # biome format --write
pnpm run build # full build
pnpm test # run tests
```
## Monorepo Structure
```
nerve/
packages/
core/ # @nerve/core — shared types and utils
cli/ # @nerve/cli — CLI entry point
daemon/ # @nerve/daemon — engine runtime
docs/ # RFCs, conventions
```
- `core` is the shared layer; `cli` and `daemon` both depend on it
- `cli` and `daemon` must NOT depend on each other
## Commit Convention
```
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: core | cli | daemon | rfc-001 | ...
```
+187 -1
View File
@@ -1,3 +1,189 @@
# nerve
Observation engine — Sense, Reflex, Workflow
**Observation engine for autonomous agents**sense the world, react to changes, run workflows.
Nerve is a lightweight daemon that continuously observes external state through **Senses**, reacts via declarative **Reflexes**, and orchestrates multi-step **Workflows**. Built for the [Uncaged](https://github.com/uncaged) agent framework.
## Core Concepts
```
External World → Sense ─┬→ Signal → Reflex → Sense (scheduled compute)
└→ Workflow (Sense return with workflow directive) → Log
```
| Concept | Metaphor | Role |
|---------|----------|------|
| **Sense** | 👁️ Perception | A `compute()` function that samples or derives data. Each sense has its own SQLite database. |
| **Reflex** | ⚡ Reaction | Declarative rules that **only schedule Sense computes** (interval and/or `on` signal names). Reflex YAML cannot reference workflows. |
| **Signal** | 📡 Notification | Emitted when a sense returns a non-null value that is routed as a normal signal (see Sense → Workflow below). Other reflexes can listen via `on`. |
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started from a Sense return value or from CLI/daemon IPC—not from reflex YAML. |
| **Log** | 📝 Record | Immutable audit trail. Queryable by senses, but **cannot** trigger reflexes (prevents feedback loops). |
**Sense → Workflow:** if `compute()` returns a plain object with a string field `workflow` in the form `name|maxRounds|prompt` (only the first two `|` delimit name and rounds; the rest is the prompt), the engine starts that workflow and **does not** emit a Signal for that return. `workflow: null` or `""` means “emit a signal” and strip the key from the payload. Invalid `workflow` strings are treated like a normal signal (directive stripped). See `@uncaged/nerve-core` `routeSenseComputeOutput` / `parseSenseWorkflowDirective`.
Three extension points for **what / when / multi-step action** — reflexes never replace Sense-driven workflow launches.
## Packages
| Package | Description |
|---------|-------------|
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, Sense→workflow routing, daemon IPC protocol |
| [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows |
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, workers, signal bus, reflex scheduler, workflow manager, file watcher, IPC |
| [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow |
## Quick Start
```bash
# Requirements: Node.js ≥ 22.5, pnpm
pnpm add -g @uncaged/nerve-cli
# Initialize a workspace
mkdir my-agent && cd my-agent
nerve init
# Write a sense
cat > senses/cpu-usage/compute.ts << 'EOF'
export async function compute() {
const [load] = (await import("node:os")).loadavg();
return load > 2.0 ? { load } : null; // signal only when load is high
}
EOF
# Configure reflexes in nerve.yaml
cat > nerve.yaml << 'EOF'
senses:
cpu-usage:
group: system
throttle: 10s
reflexes:
- kind: sense
sense: cpu-usage
interval: 30s
EOF
# Run
nerve dev # foreground (development)
nerve daemon start # background (production)
nerve status # check health
nerve logs # view logs
```
## Configuration
`nerve.yaml` declares senses, reflexes (sense-only), optional workflows (concurrency), and optional engine `max_rounds`:
```yaml
max_rounds: 100 # default moderator cap (e.g. CLI workflow trigger)
senses:
cpu-usage:
group: system # senses in the same group share a worker process
throttle: 10s # min interval between computes
timeout: 30s # max compute duration
grace_period: 5s # wait before first compute after startup
reflexes:
- kind: sense
sense: cpu-usage
interval: 30s # periodic trigger
on: [disk-pressure] # also trigger on signals from other senses
workflows:
cleanup:
concurrency: 1
overflow: drop # discard if already running
code-review:
concurrency: 3
overflow: queue
max_queue: 20
```
YAML must **not** include `workflow:` under `reflexes` — the parser rejects it. Declare workflows under `workflows:` and start them from Sense `compute()` or `nerve workflow trigger`.
**Example — Sense starts a workflow** (`senses/disk-pressure/compute.ts`):
```typescript
export async function compute() {
const full = await diskNearlyFull();
if (!full) return null;
return {
path: "/data",
workflow: "cleanup|10|Disk partition nearly full", // name|maxRounds|prompt
};
}
```
## Architecture
```
┌────────────────────────────────────────────────────────────────────────┐
│ Kernel │
│ │
│ ┌──────────────┐ watches nerve.yaml / senses / workflows │
│ │ File Watcher ├──────────────────────────────────────────┐ │
│ └──────────────┘ │ │
│ ┌──────────────┐ CLI ↔ newline JSON (trigger-workflow, │ │
│ │ Daemon IPC │ trigger-sense, list-senses) │ │
│ └──────┬───────┘ ▼ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ Worker │ │ Worker │ │ Worker │ (1 per│
│ │ │ (group A)│ │ (group B)│ │ (group C)│ group) │
│ │ │ sense-1 │ │ sense-3 │ │ sense-5 │ │
│ │ │ sense-2 │ │ sense-4 │ │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ │ └──────────────┼──────────────┘ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ Signal Bus │ │
│ │ └──────┬───────┘ │
│ │ ▼ │
│ │ ┌──────────────────┐ │
│ │ │ Reflex Scheduler│ │
│ │ └────────┬─────────┘ │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
│ └───────────────────┘ (logs.db, …) │
└────────────────────────────────────────────────────────────────────────┘
```
- **Worker pool** — one child process per sense group; isolation between groups.
- **Signal Bus** — in-memory pub/sub for signal distribution.
- **Reflex Scheduler** — interval timers + signal subscriptions, with throttle/coalesce.
- **Workflow Manager** — concurrency (drop/queue), per-workflow workers, crash recovery.
- **File watcher** — hot reload for config, sense modules, and workflow modules.
- **Daemon IPC** — Unix domain socket; used by the CLI when the daemon is running.
- **Log / blob storage** — implemented in `@uncaged/nerve-store` (WAL SQLite, JSONL archive, CAS blobs).
## Tech Stack
- **Zero native addons** — uses Node.js built-in `node:sqlite` (DatabaseSync)
- **Drizzle ORM** v1.0 for sense databases
- **rslib** (rspack) for building
- **Biome** for formatting/linting
- **Vitest** for testing
- **pnpm** workspaces for monorepo management
## Development
```bash
git clone https://git.shazhou.work/uncaged/nerve.git
cd nerve
pnpm install
pnpm build
pnpm -r test # run all tests
```
## Design Documents
- [RFC-001: Observation Engine](./docs/rfc-001-observation-engine.md) — Sense, Signal, Reflex model
- [RFC-002: Workflow Engine](./docs/rfc-002-workflow-engine.md) — Stateful workflow execution
- [Coding Conventions](./docs/coding-conventions.md)
## License
MIT
+14 -1
View File
@@ -19,7 +19,7 @@
},
"overrides": [
{
"include": ["tsup.config.ts"],
"include": ["tsup.config.ts", "*/rslib.config.ts"],
"linter": {
"rules": {
"style": {
@@ -27,6 +27,19 @@
}
}
}
},
{
"include": ["**/__tests__/**"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
}
}
],
"linter": {
+410
View File
@@ -0,0 +1,410 @@
# RFC-002: Workflow Engine
> Status: Draft
> Author: 小橘 🍊(NEKO Team)
> Date: 2026-04-22
## 1. 动机
RFC-001 定义了 Sense → Signal → Reflex 的无状态观测循环。当 Reflex 的 action 为 `StartWorkflow` 时,需要一个有状态的执行引擎来驱动多步骤工作流。本 RFC 定义 Workflow Engine 的完整设计。
## 2. 概念模型
```
Signal 循环 ──→ ThreadStart ──→ Thread 循环
(无状态,幂等,可合并) (有状态,顺序,Command Event 驱动)
Thread 产出 Log
(执行日志,供 retrospection)
```
### 2.1 核心概念
- **Workflow**:一个有状态工作流的定义,包含并发策略和溢出策略
- **Thread**:Workflow 的一次执行实例,有唯一 `runId`
- **Role**:执行具体动作的角色,有副作用(调 API、改文件等)
- **Moderator**:在 Role 之间递话筒的调度逻辑(纯函数)
- **CommandEvent**:Thread 内部的事件流,驱动 Role 之间的流转
Role 和 Moderator 是 Workflow 的内部细节,不跨 Workflow 共享,不作为顶层扩展点。
### 2.2 与 Sense 循环的边界
| | Signal 循环 | Thread 循环 |
|---|---|---|
| 状态 | 无状态 | 有状态(事件溯源) |
| 幂等性 | 幂等、可合并、可丢弃 | 严格顺序、每个 event 必须响应 |
| 触发 | Reflex | Moderator 递话筒 |
| 产出 | Signal → Bus | Log → logs.db |
**关键约束**:Thread 产出的 Log 不能触发 Reflex(见 RFC-001 §2.4),只能被 Sense 的 compute 查询用于 retrospection。
## 3. 配置
```yaml
# nerve.yaml
workflows:
cleanup:
concurrency: 1 # 同时最多 1 个 thread
overflow: drop # 已有活跃 thread 时丢弃新请求
execute-task:
concurrency: 10 # 可并行 10 个 thread
overflow: queue # 超出时排队
code-review:
concurrency: 3
overflow: queue
maxQueue: 20 # 队列上限,超出丢弃最旧请求
```
- **concurrency**:同时允许的最大活跃 Thread 数
- **overflow**:
- `drop`:丢弃,适用于幂等操作(如 cleanup)
- `queue`:排队等待,适用于每次需要执行的操作
- **maxQueue**(仅 `overflow: queue`):队列上限,默认 100,超出丢弃最旧请求
不需要 throttle——触发频率由上游 Sense 的 throttle 控制。
## 4. 用户代码结构
```
~/.uncaged-nerve/
workflows/
cleanup/
index.ts # workflow 定义
code-review/
index.ts
```
### 4.1 Workflow 定义(用户代码)
```typescript
// workflows/cleanup/index.ts
import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
const workflow: WorkflowDefinition = {
roles: {
scanner: {
execute: async (prompt, ctx) => {
// 扫描需要清理的资源
const stale = await findStaleResources();
return { type: "scan_complete", resources: stale };
},
},
cleaner: {
execute: async (prompt, ctx) => {
// 执行清理
await deleteResources(prompt.resources);
return { type: "cleanup_done", count: prompt.resources.length };
},
},
},
moderate(thread, event) {
// 纯函数:决定下一步交给谁
if (event.type === "thread_start") {
return { role: "scanner", prompt: {} };
}
if (event.type === "scan_complete") {
if (event.resources.length === 0) return null; // 结束
return { role: "cleaner", prompt: { resources: event.resources } };
}
return null; // 结束
},
};
export default workflow;
```
### 4.2 类型定义
```typescript
// 用户需要实现的接口
export type RoleExecuteFn = (
prompt: unknown,
ctx: WorkflowContext,
) => Promise<CommandEvent>;
export type Role = {
execute: RoleExecuteFn;
};
export type ModerateFn = (
thread: ThreadState,
event: CommandEvent,
) => ModerateResult | null; // null = thread 完成
export type ModerateResult = {
role: string;
prompt: unknown;
};
export type WorkflowDefinition = {
roles: Record<string, Role>;
moderate: ModerateFn;
};
export type WorkflowContext = {
runId: string;
workflowName: string;
log: (message: string) => void;
};
export type CommandEvent = {
type: string;
[key: string]: unknown;
};
export type ThreadState = {
runId: string;
events: CommandEvent[]; // 历史事件,供 moderate 做决策
};
```
## 5. 运行时架构
### 5.1 进程模型
同一个 workflow 的所有 thread 共享一个 worker 进程。`concurrency` 控制进程内的并发 async task 数。
```
nerve-engine (kernel)
├─ nerve-worker sense --group system (永驻)
├─ nerve-worker sense --group tasks (永驻)
├─ nerve-worker workflow --name cleanup (按需启动)
└─ nerve-worker workflow --name code-review (按需启动)
```
- 进程数 = workflow 种类数,不会膨胀
- 有活跃 thread 时启动,所有 thread 完成后退出(或保持待命 `idleTimeout`,默认 30s)
- 崩溃后 engine respawn worker,从持久化状态恢复 thread
### 5.2 IPC 扩展
在现有 IPC 协议基础上扩展 workflow 相关消息:
```typescript
// Parent → Workflow Worker
type StartThreadMessage = {
type: "start-thread";
runId: string;
workflow: string;
triggerPayload: unknown; // Reflex 传入的 signal payload
};
type ResumeThreadMessage = {
type: "resume-thread";
runId: string;
};
type ShutdownMessage = {
type: "shutdown";
};
// Workflow Worker → Parent
type ThreadEventMessage = {
type: "thread-event";
runId: string;
eventType: string; // queued, started, step_complete, completed, failed
payload: unknown;
};
type WorkflowReadyMessage = {
type: "ready";
};
type WorkflowErrorMessage = {
type: "error";
runId: string;
error: string;
};
```
### 5.3 Thread 生命周期
```
┌──────────────────────────────────────────┐
│ │
trigger ──→ queued ──→ started ──→ step_complete ──→ completed
│ ↑ │
│ └─────┘
└──→ failed
└──→ crashed (worker 崩溃)
```
1. Reflex `StartWorkflow` 触发 → kernel 检查 concurrency
2. 有空位 → 状态 `started`,发 `start-thread` 给 worker
3. 超出 concurrency:
- `overflow: drop` → 丢弃,写 `dropped` log
- `overflow: queue` → 状态 `queued`,等空位
4. Worker 内部循环:`moderate → execute → moderate → execute → ...`
5. `moderate` 返回 `null``completed`
6. Role `execute` 抛异常 → `failed`
7. Worker 进程崩溃 → 所有活跃 thread 标记 `crashed`
### 5.4 Workflow Worker 内部实现
```typescript
// workflow-worker.ts(engine 代码,不是用户代码)
async function runThread(
def: WorkflowDefinition,
runId: string,
triggerPayload: unknown,
sendToParent: (msg: WorkerToParentMessage) => void,
): Promise<void> {
const state: ThreadState = { runId, events: [] };
const ctx: WorkflowContext = {
runId,
workflowName,
log: (msg) => sendToParent({
type: "thread-event", runId, eventType: "step_complete", payload: { message: msg },
}),
};
// 初始事件
let event: CommandEvent = { type: "thread_start", ...triggerPayload };
while (true) {
state.events.push(event);
const next = def.moderate(state, event);
if (next === null) {
sendToParent({ type: "thread-event", runId, eventType: "completed", payload: null });
return;
}
const role = def.roles[next.role];
if (!role) {
sendToParent({ type: "error", runId, error: `Unknown role: ${next.role}` });
return;
}
event = await role.execute(next.prompt, ctx);
}
}
```
## 6. 持久化
### 6.1 事件溯源
Thread 状态以 append-only 事件流为 source of truth,写入统一的 `logs.db`
```sql
-- logs 表(已存在于 RFC-001),source = "workflow"
-- type 取值:queued, started, step_complete, completed, failed, crashed, dropped
```
### 6.2 物化表
为避免每次查活跃 workflow 都扫描全表,维护一张物化表,在写 log 的同一事务中 UPSERT:
```sql
CREATE TABLE workflow_runs (
run_id TEXT PRIMARY KEY,
workflow TEXT NOT NULL,
status TEXT NOT NULL, -- queued, started, completed, failed, crashed
ts INTEGER NOT NULL
);
CREATE INDEX idx_workflow_runs_status ON workflow_runs(status);
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs(workflow);
```
写入流程(同一事务):
```sql
BEGIN;
INSERT INTO logs (source, type, ref_id, payload, ts)
VALUES ('workflow', 'started', 'run-7', '{}', 1001);
INSERT OR REPLACE INTO workflow_runs (run_id, workflow, status, ts)
VALUES ('run-7', 'cleanup', 'started', 1001);
COMMIT;
```
物化表是 logs 的派生数据——数据丢失时可从 logs 重建。
### 6.3 崩溃恢复
Worker 重启时:
1.`workflow_runs WHERE status IN ('queued', 'started')`
2. `queued` → 重新排队
3. `started` → 从 logs 重建 `ThreadState`(事件列表),resume
幂等 workflow(`overflow: drop`)直接标记 `crashed` 重新来。
## 7. Kernel 扩展
### 7.1 WorkflowManager
Kernel 新增 `WorkflowManager` 组件:
```typescript
export type WorkflowManager = {
/** 触发一个 workflow(来自 Reflex) */
startWorkflow: (workflowName: string, payload: unknown) => void;
/** 获取活跃 thread 数 */
activeCount: (workflowName: string) => number;
/** 获取队列长度 */
queueLength: (workflowName: string) => number;
/** 停止所有 workflow */
stop: () => Promise<void>;
};
```
### 7.2 Reflex Scheduler 扩展
当前 `ReflexScheduler` 只处理 `kind: "sense"` 的 reflex。扩展为同时处理 `kind: "workflow"`
```typescript
// reflex-scheduler.ts 扩展
if (reflex.kind === "workflow") {
const workflowName = reflex.workflow;
if (reflex.on !== null && reflex.on.length > 0) {
const watchedSenses = new Set(reflex.on);
const unsub = bus.subscribe((signal) => {
if (watchedSenses.has(signal.senseId)) {
workflowManager.startWorkflow(workflowName, signal.payload);
}
});
unsubscribers.push(unsub);
}
}
```
### 7.3 热更新
| 变化 | 处理 |
|------|------|
| workflow ts 文件修改 | drain(等活跃 thread 完成,`drainTimeout` 后 force kill + 标记 crashed)→ respawn |
| nerve.yaml workflow 配置修改 | 更新 concurrency/overflow,不重启 worker |
| 新增 workflow | 下次触发时按需启动 worker |
| 删除 workflow | drain + 移除 |
## 8. 实现计划
### Phase 1:核心类型与 WorkflowManager 骨架
- [ ] `packages/core/src/types.ts` — 扩展 workflow 相关类型(`WorkflowDefinition``ThreadState``CommandEvent``ModerateResult` 等)
- [ ] `packages/daemon/src/ipc.ts` — 扩展 IPC 消息类型(`StartThreadMessage``ThreadEventMessage` 等)
- [ ] `packages/daemon/src/workflow-manager.ts` — WorkflowManager 实现(并发控制、队列管理、thread 生命周期)
- [ ] `packages/daemon/src/workflow-worker.ts` — Workflow worker 进程(加载用户代码、运行 moderate/execute 循环)
- [ ] `packages/daemon/src/log-store.ts` — 扩展 LogStore,添加 `workflow_runs` 物化表 + 查询方法
- [ ] 单元测试覆盖:并发控制、队列溢出、thread 生命周期
### Phase 2:Kernel 集成
- [ ] `packages/daemon/src/kernel.ts` — 集成 WorkflowManager,处理 workflow worker 的生命周期
- [ ] `packages/daemon/src/reflex-scheduler.ts` — 扩展支持 `kind: "workflow"` 的 reflex
- [ ] 集成测试:Sense signal → Reflex → Workflow 全链路
### Phase 3:崩溃恢复与热更新
- [ ] Worker crash recovery — 从持久化状态恢复 thread
- [ ] 热更新 — workflow 代码变更时 drain + respawn
- [ ] nerve.yaml workflow 配置变更的增量更新
### Phase 4:CLI 与用户体验
- [ ] `nerve workflow list` — 查看活跃 workflow
- [ ] `nerve workflow inspect <runId>` — 查看 thread 详情
- [ ] `nerve workflow trigger <name>` — 手动触发
- [ ] scaffold 模板 — `nerve init workflow <name>`
+80
View File
@@ -0,0 +1,80 @@
# Skill: Publish @uncaged/nerve packages to npm
## When to use
When releasing a new version of any `@uncaged/nerve-*` package to npm.
## Prerequisites
- npm login with an account that has **owner** access to the `@uncaged` org
- All tests pass: `pnpm -r run test`
- Clean working tree (no uncommitted changes)
## Packages
| Package | Path | npm |
|---------|------|-----|
| `@uncaged/nerve-core` | `packages/core` | [link](https://www.npmjs.com/package/@uncaged/nerve-core) |
| `@uncaged/nerve-daemon` | `packages/daemon` | [link](https://www.npmjs.com/package/@uncaged/nerve-daemon) |
| `@uncaged/nerve-cli` | `packages/cli` | [link](https://www.npmjs.com/package/@uncaged/nerve-cli) |
## Dependency order
`core``daemon``cli`
Always publish in this order. If `core` has changes, bump and publish it first, then update dependents.
## Steps
### 1. Ensure clean state
```bash
git checkout main && git pull origin main
pnpm install
pnpm -r run build
pnpm -r run test
```
### 2. Bump versions
Manually update `version` in each changed package's `package.json`.
Follow semver:
- **patch** (0.1.x): bug fixes, refactors
- **minor** (0.x.0): new features, non-breaking API additions
- **major** (x.0.0): breaking changes
If bumping `core`, also update the `@uncaged/nerve-core` dependency version in `daemon` and `cli` package.json. Same for `daemon``cli`.
### 3. Build
```bash
pnpm -r run build
```
### 4. Publish (in order)
```bash
# Only publish packages that have version bumps
# MUST use pnpm publish (not npm) — pnpm converts workspace:* to real versions
cd packages/core && pnpm publish --access public --no-git-checks
cd packages/daemon && pnpm publish --access public --no-git-checks
cd packages/cli && pnpm publish --access public --no-git-checks
```
### 5. Commit & tag
```bash
git add -A
git commit -m "release: @uncaged/nerve-core@X.Y.Z, @uncaged/nerve-daemon@X.Y.Z, @uncaged/nerve-cli@X.Y.Z"
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin main --tags
```
## Pitfalls
- **Don't publish without building first** — `tsup` output in `dist/` is what npm ships
- **Dependency order matters** — if you publish `daemon` before `core`, npm may resolve the old `core` version
- **`--access public`** is required for scoped packages on first publish; safe to always include
- **Check `npm whoami`** to confirm you're logged in as the right account
- **No changeset tool** — this project uses manual version bumps (no changesets/lerna)
+101
View File
@@ -0,0 +1,101 @@
# Skill: Setup nerve from scratch
## When to use
Setting up the nerve project for local development from a fresh clone.
## Prerequisites
- **Node.js** ≥ 18
- **pnpm** ≥ 9 (`npm install -g pnpm`)
- **Git** access to `git.shazhou.work`
## Steps
### 1. Clone
```bash
git clone https://git.shazhou.work/uncaged/nerve.git
cd nerve
```
### 2. Install dependencies
```bash
pnpm install
```
This installs all workspace packages and links internal dependencies (`core``daemon``cli`).
### 3. Build all packages
```bash
pnpm -r run build
```
Build order is handled automatically by pnpm workspace — `core` builds first, then `daemon`, then `cli`.
### 4. Run tests
```bash
pnpm -r run test
```
Or test individual packages:
```bash
pnpm --filter @uncaged/nerve-core test
pnpm --filter @uncaged/nerve-daemon test
pnpm --filter @uncaged/nerve-cli test
```
### 5. Try the CLI
```bash
# Link the CLI globally
cd packages/cli && npm link
# Initialize a workspace
mkdir ~/my-nerve-workspace && cd ~/my-nerve-workspace
nerve init
# Edit senses in nerve.yaml, then:
nerve start # start the daemon
nerve sense list # list registered senses
nerve stop # stop the daemon
```
### 6. Lint & format
```bash
pnpm run check # biome lint check
pnpm run format # biome auto-format
```
## Project structure
```
nerve/
├── packages/
│ ├── core/ # @uncaged/nerve-core — shared types, log store, blob store
│ ├── daemon/ # @uncaged/nerve-daemon — kernel, sense runtime, workflow manager
│ └── cli/ # @uncaged/nerve-cli — CLI commands (init, start, stop, sense, etc.)
├── docs/ # RFCs, conventions, skills
├── pnpm-workspace.yaml
└── biome.json # linter/formatter config
```
## Key conventions
- **Monorepo** with pnpm workspaces
- **ESM only** — all packages output ESM (`"type": "module"`)
- **tsup** for builds, **vitest** for tests, **biome** for lint/format
- **SQLite** (better-sqlite3) for log store and blob store
- See `docs/coding-conventions.md` for code style rules
## Pitfalls
- **Must build before test** — daemon and cli import compiled output from core
- **better-sqlite3** requires native compilation — if `pnpm install` fails, ensure you have build tools (`build-essential` on Linux, Xcode CLI tools on macOS)
- **Node 18+** required — uses native `fetch`, `crypto.randomUUID`, etc.
- **pnpm only** — don't use npm/yarn, workspace links won't resolve correctly
+70
View File
@@ -0,0 +1,70 @@
/**
* nerve-health — built-in sense that reports daemon health via IPC.
*
* When running inside a sense worker, this compute function sends a
* "health-request" to the parent kernel process and returns the
* health-response payload as its signal.
*
* Usage in nerve.yaml:
* senses:
* nerve-health:
* group: internal
* throttle: 30s
* timeout: 5s
*
* reflexes:
* - sense: nerve-health
* interval: 30s
*/
export type NerveHealth = {
uptime: number;
activeSenses: string[];
inFlightCount: number;
workerMemoryUsage: NodeJS.MemoryUsage;
workerUptime: number;
};
export async function compute(): Promise<NerveHealth | null> {
const health = await requestHealthFromKernel();
return health;
}
function requestHealthFromKernel(): Promise<NerveHealth> {
return new Promise((resolve, reject) => {
if (!process.send) {
reject(new Error("nerve-health: not running as a child process with IPC"));
return;
}
const timeout = setTimeout(() => {
process.removeListener("message", onMessage);
reject(new Error("nerve-health: health-request timed out"));
}, 5000);
function onMessage(msg: unknown): void {
if (
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "health-response"
) {
clearTimeout(timeout);
process.removeListener("message", onMessage);
const resp = msg as {
senses: string[];
inFlightCount: number;
};
resolve({
uptime: process.uptime(),
activeSenses: resp.senses,
inFlightCount: resp.inFlightCount,
workerMemoryUsage: process.memoryUsage(),
workerUptime: process.uptime(),
});
}
}
process.on("message", onMessage);
process.send({ type: "health-request" });
});
}
+6 -1
View File
@@ -1,14 +1,19 @@
{
"name": "nerve",
"private": true,
"engines": {
"node": ">=22.5.0"
},
"scripts": {
"prepare": "husky",
"build": "pnpm -r run build",
"check": "biome check .",
"format": "biome format --write ."
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"tsup": "^8.0.0",
"@rslib/core": "^0.21.3",
"husky": "^9.1.7",
"typescript": "^5.5.0"
}
}
+87
View File
@@ -0,0 +1,87 @@
# @uncaged/nerve-cli
Command-line interface for the [nerve](../../README.md) observation engine.
## Install
```bash
pnpm add -g @uncaged/nerve-cli
# or
npx @uncaged/nerve-cli
```
Requires Node.js ≥ 22.5.
## Commands
### Workspace
```bash
nerve init # Initialize a nerve workspace (installs deps, scaffolds config)
nerve validate # Validate nerve.yaml configuration
```
### Daemon management
```bash
nerve daemon start # Start the daemon (background)
nerve daemon stop # Stop the daemon
nerve daemon status # Show pid, uptime, sense names from nerve.yaml (process must exist)
nerve daemon restart # Stop then start
nerve daemon logs # Tail daemon process logs (file under workspace logs/)
```
### Development
```bash
nerve dev # Foreground kernel with file watcher + IPC (Ctrl+C stops)
```
### Querying & status
```bash
nerve logs # Tail or page the daemon text log file (path in footer; default ~/.uncaged-nerve/logs/nerve.log)
nerve status # Short daemon health summary (aliases daemon status)
```
Structured rows in `data/logs.db` are surfaced via **`nerve workflow inspect`** / **`nerve workflow list`** (and `LogStore` in code), not via `nerve logs`.
### Sense
```bash
nerve sense list # List senses (live fields from daemon IPC when running)
nerve sense trigger <name> # IPC trigger-sense — queue a compute for that sense
nerve sense query <name> # Read-only SQL on data/senses/<name>.db (optional SQL args)
nerve sense schema <name> # Print CREATE TABLE statements for that sense DB
```
### Store maintenance
```bash
nerve store archive # Move old log rows to JSONL under data/archive/logs/… (optional --vacuum)
```
### Workflows
```bash
nerve workflow list # Queued/started runs (add --all for terminal states; --workflow, --limit, --offset)
nerve workflow inspect <runId> # Run metadata + paginated workflow log lines
nerve workflow thread <runId> # Role rounds from persisted messages (--before, --budget)
nerve workflow trigger <name> # IPC trigger-workflow (daemon must be running)
# Optional JSON: --payload '{"prompt":"…","maxRounds":50}'
```
`nerve workflow trigger` sends a `trigger-workflow` line on the daemon Unix socket (same protocol as `@uncaged/nerve-core` / `parseDaemonIpcRequest`). It does not read `nerve.yaml` workflow definitions beyond what the running daemon already loaded.
### Top-level aliases
```bash
nerve start → nerve daemon start
nerve stop → nerve daemon stop
nerve status → nerve daemon status
nerve logs → nerve daemon logs
```
## License
MIT
+17 -6
View File
@@ -1,22 +1,33 @@
{
"name": "@uncaged/nerve-cli",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=22.5.0"
},
"version": "0.4.0",
"type": "module",
"bin": {
"nerve": "dist/cli.js"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsup"
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-daemon": "workspace:*",
"citty": "^0.1.6"
"@uncaged/nerve-store": "workspace:*",
"citty": "^0.1.6",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/node": "^22.0.0"
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
banner: {
js: "#!/usr/bin/env node",
},
},
],
source: {
entry: {
index: "src/index.ts",
cli: "src/cli.ts",
"daemon-bootstrap": "src/daemon-bootstrap.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
externals: ["@uncaged/nerve-daemon", "@uncaged/nerve-store"],
},
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { daemonCommand } from "../commands/daemon.js";
import { devCommand } from "../commands/dev.js";
import { daemonStartCommand } from "../commands/start.js";
describe("nerve daemon command group", () => {
it("exposes start, stop, status, restart, and logs subcommands", () => {
const subs = daemonCommand.subCommands;
expect(subs).toBeDefined();
if (!subs) {
throw new Error("expected daemonCommand.subCommands");
}
expect(Object.keys(subs).sort()).toEqual(["logs", "restart", "start", "status", "stop"]);
});
it("shares the same start command object as top-level nerve start alias", () => {
const subs = daemonCommand.subCommands;
expect(subs?.start).toBe(daemonStartCommand);
});
});
describe("nerve dev", () => {
it("is a foreground dev command", () => {
expect(devCommand.meta?.name).toBe("dev");
expect(devCommand.meta?.description).toMatch(/foreground/i);
});
});
@@ -0,0 +1,81 @@
/**
* Tests for nerve init workflow scaffold logic.
*
* We test the file-generation path by isolating the template rendering,
* not by invoking the full citty command (which calls process.exit).
*/
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildWorkflowTemplate } from "../commands/init.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-cli-init-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("buildWorkflowTemplate", () => {
it("includes the workflow name in the template", () => {
const tpl = buildWorkflowTemplate("my-workflow");
expect(tpl).toContain("my-workflow started");
});
it("contains WorkflowDefinition type import", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("WorkflowDefinition");
expect(tpl).toContain("@uncaged/nerve-daemon");
});
it("contains a moderate function that returns null to signal completion", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("return null");
expect(tpl).toContain("moderate");
});
it("contains a roles map with main role", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("roles:");
expect(tpl).toContain("main:");
});
it("uses different names per call", () => {
const a = buildWorkflowTemplate("workflow-a");
const b = buildWorkflowTemplate("workflow-b");
expect(a).toContain("workflow-a started");
expect(b).toContain("workflow-b started");
expect(a).not.toContain("workflow-b");
});
it("produces valid TypeScript syntax (no unclosed braces)", () => {
const tpl = buildWorkflowTemplate("test");
const opens = (tpl.match(/\{/g) ?? []).length;
const closes = (tpl.match(/\}/g) ?? []).length;
expect(opens).toBe(closes);
});
it("ends with export default workflow", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl.trim().endsWith("export default workflow;")).toBe(true);
});
});
describe("workflow scaffold file writing (simulated)", () => {
it("writes the template to disk correctly", () => {
const { mkdirSync, writeFileSync } = require("node:fs");
const workflowDir = join(tmpDir, "workflows", "my-task");
mkdirSync(workflowDir, { recursive: true });
const content = buildWorkflowTemplate("my-task");
writeFileSync(join(workflowDir, "index.ts"), content, "utf8");
const read = readFileSync(join(workflowDir, "index.ts"), "utf8");
expect(read).toContain("my-task started");
expect(read).toContain("WorkflowDefinition");
});
});
+258
View File
@@ -0,0 +1,258 @@
/**
* Tests for nerve logs command — pure helper functions only.
*
* We test sliceLogs and buildLogFooter without touching the filesystem or
* spawning a real process.
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_LOG_LINES, buildLogFooter, readAllLines, sliceLogs } from "../commands/logs.js";
import { logsCommand } from "../commands/logs.js";
// ---------------------------------------------------------------------------
// sliceLogs
// ---------------------------------------------------------------------------
describe("sliceLogs", () => {
const make = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
it("returns empty result for empty array", () => {
const r = sliceLogs([], 0, 50);
expect(r.lines).toHaveLength(0);
expect(r.total).toBe(0);
expect(r.nextOffset).toBeNull();
});
it("tail mode (offset=0): returns last N lines", () => {
const lines = make(100);
const r = sliceLogs(lines, 0, 10);
expect(r.lines).toHaveLength(10);
expect(r.lines[0]).toBe("line 91");
expect(r.lines[9]).toBe("line 100");
expect(r.startLine).toBe(91);
expect(r.endLine).toBe(100);
});
it("tail mode: when file shorter than limit, returns all", () => {
const lines = make(20);
const r = sliceLogs(lines, 0, 50);
expect(r.lines).toHaveLength(20);
expect(r.startLine).toBe(1);
expect(r.endLine).toBe(20);
expect(r.nextOffset).toBeNull();
});
it("tail mode: provides nextOffset when earlier lines exist", () => {
const lines = make(200);
const r = sliceLogs(lines, 0, 50);
expect(r.nextOffset).not.toBeNull();
expect(r.nextOffset).toBe(151 - 50); // startLine=151, prev page starts at 101
});
it("tail mode: nextOffset is null when showing from line 1", () => {
const lines = make(40);
const r = sliceLogs(lines, 0, 50);
expect(r.nextOffset).toBeNull();
});
it("offset mode: starts at given 1-based line number", () => {
const lines = make(100);
const r = sliceLogs(lines, 10, 5);
expect(r.lines[0]).toBe("line 10");
expect(r.startLine).toBe(10);
expect(r.endLine).toBe(14);
});
it("offset mode: clamps start to 0 for offset=1", () => {
const lines = make(50);
const r = sliceLogs(lines, 1, 10);
expect(r.startLine).toBe(1);
});
it("offset mode: nextOffset is null when slice starts at line 1", () => {
const lines = make(50);
const r = sliceLogs(lines, 1, 20);
expect(r.nextOffset).toBeNull();
});
it("offset mode: nextOffset points to previous page", () => {
const lines = make(100);
const r = sliceLogs(lines, 51, 50); // lines 51-100
expect(r.nextOffset).toBe(1); // previous page starts at line 1
});
});
// ---------------------------------------------------------------------------
// buildLogFooter
// ---------------------------------------------------------------------------
describe("buildLogFooter", () => {
it("returns empty-file message when total=0", () => {
const slice = { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
expect(buildLogFooter(slice, 50, "/path/to/nerve.log")).toContain("empty");
});
it("includes range and path in footer", () => {
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
const footer = buildLogFooter(slice, 50, "/var/log/nerve.log");
expect(footer).toContain("lines 151-200 of 200");
expect(footer).toContain("/var/log/nerve.log");
});
it("includes pagination hint when nextOffset is set", () => {
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
expect(footer).toContain("nerve logs --offset 101 -n 50");
});
it("no pagination hint when nextOffset is null", () => {
const slice = { lines: ["x"], total: 20, startLine: 1, endLine: 20, nextOffset: null };
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
expect(footer).not.toContain("nerve logs --offset");
});
});
// ---------------------------------------------------------------------------
// DEFAULT_LOG_LINES constant
// ---------------------------------------------------------------------------
describe("DEFAULT_LOG_LINES", () => {
it("is 50", () => {
expect(DEFAULT_LOG_LINES).toBe(50);
});
});
// ---------------------------------------------------------------------------
// readAllLines
// ---------------------------------------------------------------------------
describe("readAllLines", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("returns empty array for nonexistent file", async () => {
const result = await readAllLines(join(tmpDir, "missing.log"));
expect(result).toHaveLength(0);
});
it("reads all lines from a file", async () => {
const logFile = join(tmpDir, "test.log");
writeFileSync(logFile, "line1\nline2\nline3\n");
const result = await readAllLines(logFile);
expect(result).toEqual(["line1", "line2", "line3"]);
});
it("handles file with no trailing newline", async () => {
const logFile = join(tmpDir, "test.log");
writeFileSync(logFile, "a\nb\nc");
const result = await readAllLines(logFile);
expect(result).toEqual(["a", "b", "c"]);
});
it("returns empty array for empty file", async () => {
const logFile = join(tmpDir, "empty.log");
writeFileSync(logFile, "");
const result = await readAllLines(logFile);
expect(result).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Integration: readAllLines + sliceLogs end-to-end
// ---------------------------------------------------------------------------
describe("readAllLines + sliceLogs integration", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-int-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("tail-paginates a large log file correctly", async () => {
const logFile = join(tmpDir, "big.log");
const content = Array.from({ length: 120 }, (_, i) => `entry ${i + 1}`).join("\n");
writeFileSync(logFile, content);
const all = await readAllLines(logFile);
const page1 = sliceLogs(all, 0, 50); // last 50: lines 71-120
expect(page1.startLine).toBe(71);
expect(page1.endLine).toBe(120);
expect(page1.nextOffset).toBe(21); // max(1, 71-50)
const page2 = sliceLogs(all, page1.nextOffset!, 50); // lines 21-70
expect(page2.startLine).toBe(21);
expect(page2.endLine).toBe(70);
expect(page2.nextOffset).toBe(1); // max(1, 21-50) = 1
const page3 = sliceLogs(all, page2.nextOffset!, 50); // lines 1-50
expect(page3.startLine).toBe(1);
expect(page3.endLine).toBe(50);
expect(page3.nextOffset).toBeNull();
});
});
// ---------------------------------------------------------------------------
// logsCommand: negative offset validation
// ---------------------------------------------------------------------------
describe("logsCommand negative offset", () => {
let stderrOutput: string;
let exitCode: number | undefined;
beforeEach(() => {
stderrOutput = "";
exitCode = undefined;
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderrOutput += typeof chunk === "string" ? chunk : chunk.toString();
return true;
});
vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
exitCode = typeof code === "number" ? code : 1;
throw new Error(`process.exit(${exitCode})`);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("exits with code 1 and writes to stderr when offset is negative", async () => {
await expect(
logsCommand.run?.({
args: { n: "50", offset: "-5", follow: false },
rawArgs: [],
cmd: logsCommand as never,
}),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
expect(stderrOutput).toContain("--offset must be a non-negative integer");
expect(stderrOutput).toContain("-5");
});
it("exits with code 1 for offset=-1", async () => {
await expect(
logsCommand.run?.({
args: { n: "10", offset: "-1", follow: false },
rawArgs: [],
cmd: logsCommand as never,
}),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
});
});
@@ -0,0 +1,327 @@
/**
* Tests for `nerve sense list` — formatting helpers and IPC round-trip.
*
* Covers:
* - formatDuration helper
* - formatSenseList output
* - sensesFromConfig (static fallback from nerve.yaml)
* - listSensesViaDaemon IPC round-trip via real Unix socket
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SenseInfo } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { formatDuration, formatSenseList, sensesFromConfig } from "../commands/sense.js";
import { listSensesViaDaemon } from "../daemon-client.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SAMPLE_SENSES: SenseInfo[] = [
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTimestamp: 1_700_000_000_000,
},
{
name: "disk-usage",
group: "system",
throttle: 30000,
timeout: null,
lastSignalTimestamp: null,
},
{
name: "active-tasks",
group: "tasks",
throttle: 10000,
timeout: 30000,
lastSignalTimestamp: null,
},
];
// ---------------------------------------------------------------------------
// formatDuration
// ---------------------------------------------------------------------------
describe("formatDuration", () => {
it("returns '—' for null", () => {
expect(formatDuration(null)).toBe("—");
});
it("formats sub-minute durations as seconds", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(1000)).toBe("1s");
expect(formatDuration(59000)).toBe("59s");
});
it("formats minute-range durations as Xm Ys", () => {
expect(formatDuration(60000)).toBe("1m 0s");
expect(formatDuration(90000)).toBe("1m 30s");
expect(formatDuration(3599000)).toBe("59m 59s");
});
it("formats hour-range durations as Xh Ym", () => {
expect(formatDuration(3600000)).toBe("1h 0m");
expect(formatDuration(3660000)).toBe("1h 1m");
expect(formatDuration(7200000)).toBe("2h 0m");
});
});
// ---------------------------------------------------------------------------
// formatSenseList
// ---------------------------------------------------------------------------
describe("formatSenseList", () => {
it("returns empty message when no senses", () => {
const output = formatSenseList([]);
expect(output).toContain("No senses registered");
});
it("shows sense count in header", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("3");
});
it("shows each sense name", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("cpu-usage");
expect(output).toContain("disk-usage");
expect(output).toContain("active-tasks");
});
it("shows group for each sense", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("system");
expect(output).toContain("tasks");
});
it("shows throttle and timeout durations", () => {
const output = formatSenseList(SAMPLE_SENSES);
// cpu-usage: throttle=5s, timeout=3s
expect(output).toContain("5s");
expect(output).toContain("3s");
// disk-usage: timeout=null → '—'
expect(output).toContain("—");
});
it("shows '(never)' when lastSignalTimestamp is null", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("(never)");
});
it("shows ISO timestamp when lastSignalTimestamp is set", () => {
const output = formatSenseList(SAMPLE_SENSES);
// cpu-usage has lastSignalTimestamp = 1_700_000_000_000
expect(output).toContain(new Date(1_700_000_000_000).toISOString());
});
});
// ---------------------------------------------------------------------------
// sensesFromConfig — static fallback from nerve.yaml
// ---------------------------------------------------------------------------
describe("sensesFromConfig", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("returns empty array when file does not exist", () => {
const result = sensesFromConfig(join(tmpDir, "nonexistent.yaml"));
expect(result).toEqual([]);
});
it("returns empty array when file has invalid YAML", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(path, "not: valid: yaml: :::");
const result = sensesFromConfig(path);
expect(result).toEqual([]);
});
it("parses senses from valid nerve.yaml", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
cpu-usage:
group: system
throttle: 5s
timeout: 3s
disk-usage:
group: system
throttle: 30s
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
name: "cpu-usage",
group: "system",
lastSignalTimestamp: null,
});
expect(result[1]).toMatchObject({
name: "disk-usage",
group: "system",
lastSignalTimestamp: null,
});
});
it("always sets lastSignalTimestamp to null (static fallback)", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
my-sense:
group: default
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result[0].lastSignalTimestamp).toBeNull();
});
it("populates throttle and timeout from config", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
my-sense:
group: default
throttle: 10s
timeout: 5s
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result[0].throttle).toBe(10000);
expect(result[0].timeout).toBe(5000);
});
});
// ---------------------------------------------------------------------------
// listSensesViaDaemon — IPC round-trip via real Unix socket
// ---------------------------------------------------------------------------
describe("listSensesViaDaemon", () => {
let sockDir: string;
let sockPath: string;
beforeEach(() => {
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-ipc-"));
sockPath = join(sockDir, "nerve.sock");
});
afterEach(() => {
rmSync(sockDir, { recursive: true, force: true });
});
it("resolves with { ok: true, senses: [] } when daemon returns empty list", async () => {
const server = createServer((s) => {
s.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
const req = JSON.parse(line) as { type: string };
if (req.type === "list-senses") {
s.write(`${JSON.stringify({ ok: true, senses: [] })}\n`);
}
} catch {
// ignore
}
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await listSensesViaDaemon(sockPath);
expect(result).toEqual({ ok: true, senses: [] });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("resolves with populated senses array", async () => {
const senses: SenseInfo[] = [
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTimestamp: 12345,
},
];
const server = createServer((s) => {
s.on("data", () => {
s.write(`${JSON.stringify({ ok: true, senses })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await listSensesViaDaemon(sockPath);
expect(result).toEqual({ ok: true, senses });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("resolves with { ok: false, error } when daemon returns an error", async () => {
const server = createServer((s) => {
s.on("data", () => {
s.write(`${JSON.stringify({ ok: false, error: "something went wrong" })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await listSensesViaDaemon(sockPath);
expect(result).toEqual({ ok: false, error: "something went wrong" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("rejects when no daemon is listening on the socket", async () => {
await expect(listSensesViaDaemon(sockPath)).rejects.toThrow(/Cannot connect to daemon/);
});
it("sends a list-senses IPC message to the daemon", async () => {
const received: unknown[] = [];
const server = createServer((s) => {
s.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
received.push(JSON.parse(line));
} catch {
// ignore
}
s.write(`${JSON.stringify({ ok: true, senses: [] })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
await listSensesViaDaemon(sockPath);
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ type: "list-senses" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
});
@@ -0,0 +1,159 @@
/**
* Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`.
*/
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertSenseDbExists,
collectColumnKeys,
defaultPreviewSql,
formatRowsAsAlignedTable,
listTableSqlStatements,
parseSenseQueryArgs,
pickDefaultPreviewTable,
senseDbPath,
} from "../sense-sqlite.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = join(
tmpdir(),
`nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
mkdirSync(join(tmpDir, "data", "senses"), { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("senseDbPath", () => {
it("points at data/senses/<name>.db under the given root", () => {
expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db"));
});
});
describe("assertSenseDbExists", () => {
it("throws when the file is missing", () => {
expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/);
});
it("returns the path when the file exists", () => {
const p = join(tmpDir, "data", "senses", "x.db");
new DatabaseSync(p).close();
expect(assertSenseDbExists(tmpDir, "x")).toBe(p);
});
});
describe("listTableSqlStatements", () => {
it("returns CREATE statements ordered by tbl_name", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE zebra (id INTEGER)");
db.exec("CREATE TABLE alpha (id INTEGER)");
const stmts = listTableSqlStatements(db);
db.close();
expect(stmts).toHaveLength(2);
expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i);
expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i);
});
});
describe("pickDefaultPreviewTable", () => {
it("prefers non-_migrations tables when both exist", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
db.exec("CREATE TABLE readings (id INTEGER)");
expect(pickDefaultPreviewTable(db)).toBe("readings");
db.close();
});
it("uses _migrations when it is the only table", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
expect(pickDefaultPreviewTable(db)).toBe("_migrations");
db.close();
});
});
describe("defaultPreviewSql", () => {
it("quotes identifiers for SQL safety", () => {
expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`);
});
});
describe("parseSenseQueryArgs", () => {
it("parses sense name only", () => {
expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined });
});
it("strips --json", () => {
expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined });
expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined });
});
it("joins remaining tokens into SQL", () => {
expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" });
});
it("throws when name is missing", () => {
expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/);
});
});
describe("formatRowsAsAlignedTable", () => {
it("shows empty marker for no rows", () => {
expect(formatRowsAsAlignedTable([])).toContain("(0 rows)");
});
it("aligns columns from row data", () => {
const out = formatRowsAsAlignedTable([
{ a: 1, b: "x" },
{ a: 22, b: "yy" },
]);
expect(out).toContain("a");
expect(out).toContain("b");
expect(out).toContain("22");
});
});
describe("collectColumnKeys", () => {
it("preserves key order from first row then appends new keys", () => {
expect(
collectColumnKeys([
{ z: 1, a: 2 },
{ a: 3, b: 4 },
]),
).toEqual(["z", "a", "b"]);
});
});
describe("readonly query integration", () => {
it("runs default preview SQL on a real db", () => {
const p = join(tmpDir, "data", "senses", "demo.db");
const rw = new DatabaseSync(p);
rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)");
rw.exec("INSERT INTO items (v) VALUES ('a'), ('b')");
rw.close();
const db = new DatabaseSync(p, { readOnly: true });
const table = pickDefaultPreviewTable(db);
expect(table).toBe("items");
if (table === null) {
throw new Error("expected items table");
}
const sql = defaultPreviewSql(table);
const rows = db.prepare(sql).all() as Record<string, unknown>[];
db.close();
expect(rows.length).toBeGreaterThanOrEqual(1);
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* Tests for the sense CLI helper — triggerSenseViaDaemon IPC round-trip.
*
* Uses a real Unix socket server to validate the full client/server
* protocol without requiring a running daemon process.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { triggerSenseViaDaemon } from "../daemon-client.js";
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
let sockDir: string;
let sockPath: string;
beforeEach(() => {
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-test-"));
sockPath = join(sockDir, "nerve.sock");
});
afterEach(() => {
rmSync(sockDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// triggerSenseViaDaemon — IPC round-trip via real Unix socket
// ---------------------------------------------------------------------------
describe("triggerSenseViaDaemon", () => {
it("resolves { ok: true } when daemon responds ok", async () => {
const received: unknown[] = [];
const server = createServer((s) => {
s.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
received.push(JSON.parse(line));
} catch {
// ignore
}
s.write(`${JSON.stringify({ ok: true })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await triggerSenseViaDaemon(sockPath, "cpu-usage");
expect(result).toEqual({ ok: true });
// Verify the correct IPC message was sent
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ type: "trigger-sense", sense: "cpu-usage" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("resolves { ok: false, error } when daemon rejects the sense", async () => {
const server = createServer((s) => {
s.on("data", () => {
s.write(`${JSON.stringify({ ok: false, error: 'Unknown sense: "no-such-sense"' })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await triggerSenseViaDaemon(sockPath, "no-such-sense");
expect(result).toEqual({ ok: false, error: 'Unknown sense: "no-such-sense"' });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("rejects when no daemon is listening on the socket", async () => {
await expect(triggerSenseViaDaemon(sockPath, "cpu-usage")).rejects.toThrow(
/Cannot connect to daemon/,
);
});
it("sends the sense name exactly as provided", async () => {
const received: unknown[] = [];
const server = createServer((s) => {
s.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
received.push(JSON.parse(line));
} catch {
// ignore
}
s.write(`${JSON.stringify({ ok: true })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
await triggerSenseViaDaemon(sockPath, "my-custom-sense");
expect(received[0]).toMatchObject({ sense: "my-custom-sense" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
});
+546
View File
@@ -0,0 +1,546 @@
/**
* Tests for workflow CLI commands — pure logic helpers.
*
* Tests do NOT invoke the citty command handlers directly (they would call
* process.exit / process.stdout.write against a real terminal). Instead we
* test the exported pure helper functions that the command handlers delegate
* to. The helpers use real LogStore / SQLite via temp directories.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createLogStore } from "@uncaged/nerve-store";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
import {
DEFAULT_THREAD_BUDGET_CHARS,
buildInspectOutput,
buildListOutput,
buildThreadCommandOutput,
formatThreadRoundBlock,
formatTs,
getAllWorkflowRuns,
parseIntArg,
partitionWorkflowMessage,
statusIcon,
} from "../commands/workflow.js";
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
let tmpDir: string;
let store: LogStore;
function upsertRun(
runId: string,
workflow: string,
status: WorkflowRun["status"],
timestampMs: number,
): void {
store.upsertWorkflowRun(
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: timestampMs },
{ runId, workflow, status, timestamp: timestampMs, exitCode: null },
);
}
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-cli-wf-test-"));
store = createLogStore(join(tmpDir, "data", "logs.db"));
});
afterEach(() => {
store.close();
rmSync(tmpDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// formatTs
// ---------------------------------------------------------------------------
describe("formatTs", () => {
it("returns ISO 8601 string", () => {
const timestampMs = new Date("2026-01-01T00:00:00.000Z").getTime();
expect(formatTs(timestampMs)).toBe("2026-01-01T00:00:00.000Z");
});
});
// ---------------------------------------------------------------------------
// statusIcon
// ---------------------------------------------------------------------------
describe("statusIcon", () => {
it.each([
["started", "▶"],
["queued", "⏳"],
["completed", "✅"],
["failed", "❌"],
["crashed", "💥"],
["dropped", "🗑"],
["interrupted", "⚠️"],
["killed", "🛑"],
] as const)("maps status=%s to icon=%s", (status, icon) => {
expect(statusIcon(status)).toBe(icon);
});
});
// ---------------------------------------------------------------------------
// getAllWorkflowRuns
// ---------------------------------------------------------------------------
describe("getAllWorkflowRuns", () => {
it("returns empty array when no runs exist", () => {
expect(getAllWorkflowRuns(store, null)).toHaveLength(0);
});
it("returns all runs across statuses", () => {
upsertRun("r1", "cleanup", "completed", 1000);
upsertRun("r2", "cleanup", "started", 2000);
upsertRun("r3", "deploy", "failed", 1500);
const runs = getAllWorkflowRuns(store, null);
expect(runs).toHaveLength(3);
});
it("deduplicates runs by runId (latest state only)", () => {
upsertRun("r1", "cleanup", "started", 1000);
upsertRun("r1", "cleanup", "completed", 2000);
const runs = getAllWorkflowRuns(store, null);
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe("completed");
});
it("filters by workflow name", () => {
upsertRun("r1", "cleanup", "completed", 1000);
upsertRun("r2", "deploy", "started", 2000);
upsertRun("r3", "cleanup", "failed", 1500);
const runs = getAllWorkflowRuns(store, "cleanup");
expect(runs).toHaveLength(2);
for (const r of runs) {
expect(r.workflow).toBe("cleanup");
}
});
it("sorts by timestamp descending (newest first)", () => {
upsertRun("r1", "cleanup", "completed", 1000);
upsertRun("r2", "cleanup", "started", 3000);
upsertRun("r3", "cleanup", "failed", 2000);
const runs = getAllWorkflowRuns(store, null);
expect(runs[0].timestamp).toBeGreaterThan(runs[1].timestamp);
expect(runs[1].timestamp).toBeGreaterThan(runs[2].timestamp);
});
});
// ---------------------------------------------------------------------------
// buildListOutput
// ---------------------------------------------------------------------------
describe("buildListOutput", () => {
function makeRun(
runId: string,
workflow: string,
status: WorkflowRun["status"],
timestampMs: number,
): WorkflowRun {
return { runId, workflow, status, timestamp: timestampMs };
}
it("returns empty message when no runs and --all=false", () => {
const { lines, paginationHint } = buildListOutput([], 0, 20, false, null);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain("--all");
expect(paginationHint).toBeNull();
});
it("returns empty message when no runs and --all=true", () => {
const { lines, paginationHint } = buildListOutput([], 0, 20, true, null);
expect(lines).toHaveLength(1);
expect(lines[0]).not.toContain("--all");
expect(paginationHint).toBeNull();
});
it("shows correct run count in header", () => {
const runs = [
makeRun("r1", "cleanup", "started", 1000),
makeRun("r2", "cleanup", "queued", 2000),
];
const { lines } = buildListOutput(runs, 0, 20, false, null);
expect(lines[0]).toContain("2 of 2");
});
it("includes run details in lines", () => {
const runs = [makeRun("run-abc", "my-workflow", "started", 1000)];
const { lines } = buildListOutput(runs, 0, 20, false, null);
const combined = lines.join("");
expect(combined).toContain("run-abc");
expect(combined).toContain("my-workflow");
expect(combined).toContain("started");
expect(combined).toContain("▶");
});
it("paginates: shows only limit entries and provides hint", () => {
const runs = Array.from({ length: 5 }, (_, i) => makeRun(`r${i}`, "wf", "completed", i * 1000));
const { lines, paginationHint } = buildListOutput(runs, 0, 2, true, null);
// header + 2 run lines
expect(lines).toHaveLength(3);
expect(paginationHint).not.toBeNull();
expect(paginationHint).toContain("--offset 2");
expect(paginationHint).toContain("3 more");
});
it("pagination hint includes --all flag when set", () => {
const runs = Array.from({ length: 3 }, (_, i) => makeRun(`r${i}`, "wf", "completed", i * 1000));
const { paginationHint } = buildListOutput(runs, 0, 1, true, null);
expect(paginationHint).toContain("--all");
});
it("pagination hint includes --workflow filter when set", () => {
const runs = Array.from({ length: 3 }, (_, i) =>
makeRun(`r${i}`, "cleanup", "completed", i * 1000),
);
const { paginationHint } = buildListOutput(runs, 0, 1, false, "cleanup");
expect(paginationHint).toContain("--workflow cleanup");
});
it("no pagination hint when all entries fit on one page", () => {
const runs = [makeRun("r1", "wf", "started", 1000)];
const { paginationHint } = buildListOutput(runs, 0, 20, false, null);
expect(paginationHint).toBeNull();
});
it("respects offset for pagination", () => {
const runs = Array.from({ length: 5 }, (_, i) => makeRun(`r${i}`, "wf", "completed", i * 1000));
const { lines, paginationHint } = buildListOutput(runs, 2, 2, true, null);
// header + 2 run lines (offset=2, limit=2 gives items 2 and 3)
expect(lines).toHaveLength(3);
// 1 item remaining (index 4)
expect(paginationHint).toContain("1 more");
expect(paginationHint).toContain("--offset 4");
});
});
// ---------------------------------------------------------------------------
// buildInspectOutput
// ---------------------------------------------------------------------------
describe("buildInspectOutput", () => {
const baseRun: WorkflowRun = {
runId: "run-xyz",
workflow: "cleanup",
status: "completed",
timestamp: 1_700_000_000_000,
};
it("shows header with run details", () => {
const { header } = buildInspectOutput(baseRun, [], 0, 20);
const headerText = header.join("");
expect(headerText).toContain("run-xyz");
expect(headerText).toContain("cleanup");
expect(headerText).toContain("completed");
});
it("shows '(no events recorded)' when log is empty", () => {
const { eventLines } = buildInspectOutput(baseRun, [], 0, 20);
expect(eventLines.join("")).toContain("no events recorded");
});
it("shows event lines with type and timestamp", () => {
const logs = [{ timestamp: 1_700_000_001_000, type: "started", payload: null }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
const text = eventLines.join("");
expect(text).toContain("type=started");
});
it("truncates long payloads to 200 chars with ellipsis", () => {
const longPayload = "x".repeat(250);
const logs = [{ timestamp: 1000, type: "step_complete", payload: longPayload }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
const text = eventLines.join("");
expect(text).toContain("…");
expect(text).not.toContain("x".repeat(201));
});
it("shows short payloads in full", () => {
const logs = [{ timestamp: 1000, type: "step_complete", payload: '{"count":5}' }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
expect(eventLines.join("")).toContain('{"count":5}');
});
it("paginates events with a hint", () => {
const logs = Array.from({ length: 5 }, (_, i) => ({
timestamp: 1000 + i,
type: "step_complete",
payload: null,
}));
const { eventLines, paginationHint } = buildInspectOutput(baseRun, logs, 0, 2);
expect(eventLines).toHaveLength(2);
expect(paginationHint).toContain("3 more");
expect(paginationHint).toContain("--offset 2");
expect(paginationHint).toContain("run-xyz");
});
it("no pagination hint when all events fit on one page", () => {
const logs = [{ timestamp: 1000, type: "started", payload: null }];
const { paginationHint } = buildInspectOutput(baseRun, logs, 0, 20);
expect(paginationHint).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Integration: getAllWorkflowRuns + buildListOutput end-to-end with real store
// ---------------------------------------------------------------------------
describe("workflow list — integration with real store", () => {
it("lists active runs from the store", () => {
upsertRun("r1", "cleanup", "started", 1000);
upsertRun("r2", "cleanup", "queued", 2000);
upsertRun("r3", "cleanup", "completed", 3000);
// Active only (getActiveWorkflowRuns)
const activeRuns = store.getActiveWorkflowRuns();
const { lines } = buildListOutput(activeRuns, 0, 20, false, null);
const combined = lines.join("");
expect(combined).toContain("r1");
expect(combined).toContain("r2");
expect(combined).not.toContain("r3");
});
it("lists all runs with getAllWorkflowRuns", () => {
upsertRun("r1", "cleanup", "started", 1000);
upsertRun("r2", "cleanup", "completed", 2000);
upsertRun("r3", "cleanup", "failed", 3000);
const allRuns = getAllWorkflowRuns(store, null);
const { lines } = buildListOutput(allRuns, 0, 20, true, null);
const combined = lines.join("");
expect(combined).toContain("r1");
expect(combined).toContain("r2");
expect(combined).toContain("r3");
});
});
// ---------------------------------------------------------------------------
// nerve workflow thread — formatting helpers
// ---------------------------------------------------------------------------
describe("partitionWorkflowMessage", () => {
it("extracts role, content, and meta", () => {
const p = partitionWorkflowMessage({
role: "scanner",
content: "ok",
meta: { items: [1, 2] },
});
expect(p.roleStr).toBe("scanner");
expect(p.contentBody).toBe("ok");
expect(p.meta).toEqual({ items: [1, 2] });
});
it("passes through role and content as-is", () => {
const p = partitionWorkflowMessage({
role: "unknown",
content: '{"n":1}',
meta: null,
timestamp: 0,
});
expect(p.roleStr).toBe("unknown");
expect(p.contentBody).toBe('{"n":1}');
});
});
describe("formatThreadRoundBlock", () => {
const row: ThreadRoundRow = {
round: 2,
logId: 99,
timestamp: new Date("2026-01-02T03:04:05.006Z").getTime(),
message: { role: "bot", content: "hi", meta: { score: 0.5 }, timestamp: 1735783445006 },
};
it("includes header, YAML frontmatter for meta, and body", () => {
const text = formatThreadRoundBlock(row);
expect(text).toContain("[#2 bot]");
expect(text).toContain("---\n");
expect(text).toContain("score: 0.5");
expect(text).toContain("hi");
});
});
describe("buildThreadCommandOutput", () => {
function row(n: number, content: string): ThreadRoundRow {
return {
round: n,
logId: 10 + n,
timestamp: 1000 + n,
message: { role: "r", content, meta: { extra: n }, timestamp: 1000 + n },
};
}
it("orders rounds chronologically (oldest first in output)", () => {
const desc = [row(3, "ccc"), row(2, "bbb"), row(1, "aaa")];
const prefix = ["HEADER\n"];
const { lines, paginationHint } = buildThreadCommandOutput(prefix, desc, 50_000, "run-x");
const text = lines.join("");
const idxA = text.indexOf("\naaa\n");
const idxB = text.indexOf("\nbbb\n");
const idxC = text.indexOf("\nccc\n");
expect(idxA).toBeGreaterThan(-1);
expect(idxB).toBeGreaterThan(idxA);
expect(idxC).toBeGreaterThan(idxB);
expect(paginationHint).toBeNull();
});
it("emits pagination hint with --before when oldest shown round is still > 1", () => {
const desc = [row(4, "d"), row(3, "c")];
const { paginationHint } = buildThreadCommandOutput([], desc, 50_000, "run-y");
expect(paginationHint).toContain("--before 3");
expect(paginationHint).toContain("run-y");
});
it("respects budget and hints with non-default --budget in command", () => {
const big = "y".repeat(500);
const desc = [row(2, big), row(1, "a")];
const { lines, paginationHint } = buildThreadCommandOutput([], desc, 400, "run-z");
const text = lines.join("");
expect(text).toContain("[#2");
expect(text).not.toContain("[#1");
expect(paginationHint).toContain("--before 2");
expect(paginationHint).toContain("--budget 400");
});
it("default budget constant matches workflow command default", () => {
expect(DEFAULT_THREAD_BUDGET_CHARS).toBe(8000);
});
});
// ---------------------------------------------------------------------------
// parseIntArg
// ---------------------------------------------------------------------------
describe("parseIntArg", () => {
it("parses a valid integer string", () => {
expect(parseIntArg("5", 20)).toBe(5);
});
it("returns fallback for non-numeric string", () => {
expect(parseIntArg("abc", 20)).toBe(20);
});
it("returns the value for '0' (not fallback)", () => {
expect(parseIntArg("0", 20)).toBe(0);
});
it("returns fallback for empty string", () => {
expect(parseIntArg("", 20)).toBe(20);
});
it("parses negative integers", () => {
expect(parseIntArg("-3", 20)).toBe(-3);
});
});
// ---------------------------------------------------------------------------
// getAllWorkflowRuns — backed by real store's SQL query
// ---------------------------------------------------------------------------
describe("getAllWorkflowRuns — uses store.getAllWorkflowRuns SQL path", () => {
it("returns all runs regardless of status", () => {
upsertRun("r1", "deploy", "completed", 1000);
upsertRun("r2", "deploy", "failed", 2000);
upsertRun("r3", "deploy", "started", 3000);
upsertRun("r4", "deploy", "queued", 4000);
upsertRun("r5", "deploy", "crashed", 5000);
upsertRun("r6", "deploy", "dropped", 6000);
upsertRun("r7", "deploy", "interrupted", 7000);
const runs = getAllWorkflowRuns(store, null);
expect(runs).toHaveLength(7);
});
it("returns runs sorted by timestamp descending (newest first)", () => {
upsertRun("r1", "deploy", "completed", 1000);
upsertRun("r2", "deploy", "completed", 3000);
upsertRun("r3", "deploy", "completed", 2000);
const runs = getAllWorkflowRuns(store, null);
expect(runs[0].timestamp).toBe(3000);
expect(runs[1].timestamp).toBe(2000);
expect(runs[2].timestamp).toBe(1000);
});
it("filters by workflow name", () => {
upsertRun("r1", "alpha", "completed", 1000);
upsertRun("r2", "beta", "completed", 2000);
upsertRun("r3", "alpha", "failed", 3000);
const runs = getAllWorkflowRuns(store, "alpha");
expect(runs).toHaveLength(2);
for (const r of runs) expect(r.workflow).toBe("alpha");
});
it("returns empty array when store has no runs", () => {
expect(getAllWorkflowRuns(store, null)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// triggerWorkflowViaDaemon — IPC round-trip via real Unix socket
// ---------------------------------------------------------------------------
describe("triggerWorkflowViaDaemon", () => {
let sockDir: string;
let sockPath: string;
beforeEach(() => {
sockDir = mkdtempSync(join(tmpdir(), "nerve-ipc-test-"));
sockPath = join(sockDir, "nerve.sock");
});
afterEach(() => {
rmSync(sockDir, { recursive: true, force: true });
});
it("resolves { ok: true } when server responds ok", async () => {
const server = createServer((s) => {
s.on("data", () => {
s.write(`${JSON.stringify({ ok: true })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await triggerWorkflowViaDaemon(sockPath, "my-workflow", "", 100);
expect(result).toEqual({ ok: true });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("resolves { ok: false, error } when server responds with error", async () => {
const server = createServer((s) => {
s.on("data", () => {
s.write(`${JSON.stringify({ ok: false, error: "unknown workflow" })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await triggerWorkflowViaDaemon(sockPath, "missing", "", 100);
expect(result).toEqual({ ok: false, error: "unknown workflow" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("rejects when no daemon is listening on the socket", async () => {
await expect(triggerWorkflowViaDaemon(sockPath, "my-workflow", "", 100)).rejects.toThrow(
/Cannot connect to daemon/,
);
});
});
+35 -5
View File
@@ -1,12 +1,36 @@
#!/usr/bin/env node
import { defineCommand, runMain } from "citty";
import { daemonCommand } from "./commands/daemon.js";
import { devCommand } from "./commands/dev.js";
import { initCommand } from "./commands/init.js";
import { startCommand } from "./commands/start.js";
import { logsCommand } from "./commands/logs.js";
import { senseCommand } from "./commands/sense.js";
import { daemonStartCommand } from "./commands/start.js";
import { statusCommand } from "./commands/status.js";
import { stopCommand } from "./commands/stop.js";
import { storeCommand } from "./commands/store.js";
import { validateCommand } from "./commands/validate.js";
import { workflowCommand } from "./commands/workflow.js";
/**
* Citty picks the first non-flag token as a subcommand name. Rewrite
* `nerve init --from <url>` so the URL is not mistaken for `workflow`/`workspace`.
*/
function normalizeNerveArgv(argv: string[]): string[] {
const initIdx = argv.indexOf("init");
if (initIdx === -1) return argv;
const tail = argv.slice(initIdx + 1);
const fromAt = tail.indexOf("--from");
if (fromAt === -1) return argv;
const beforeFrom = tail.slice(0, fromAt);
if (beforeFrom.some((a) => !a.startsWith("-"))) return argv;
const next = tail[fromAt + 1];
if (next === undefined || next.startsWith("-")) return argv;
const reserved = new Set(["workflow", "workspace"]);
if (reserved.has(next)) return argv;
const mergedTail = [...tail.slice(0, fromAt), `--from=${next}`, ...tail.slice(fromAt + 2)];
return [...argv.slice(0, initIdx + 1), ...mergedTail];
}
const main = defineCommand({
meta: {
@@ -15,11 +39,17 @@ const main = defineCommand({
},
subCommands: {
init: initCommand,
start: startCommand,
daemon: daemonCommand,
dev: devCommand,
start: daemonStartCommand,
stop: stopCommand,
status: statusCommand,
logs: logsCommand,
validate: validateCommand,
sense: senseCommand,
store: storeCommand,
workflow: workflowCommand,
},
});
runMain(main);
runMain(main, { rawArgs: normalizeNerveArgv(process.argv.slice(2)) });
+31
View File
@@ -0,0 +1,31 @@
import { defineCommand } from "citty";
import { logsCommand } from "./logs.js";
import { daemonStartCommand, runDaemonStartCommand } from "./start.js";
import { statusCommand } from "./status.js";
import { runStopCommand, stopCommand } from "./stop.js";
const daemonRestartCommand = defineCommand({
meta: {
name: "restart",
description: "Stop then start the nerve daemon",
},
async run() {
await runStopCommand();
await runDaemonStartCommand();
},
});
export const daemonCommand = defineCommand({
meta: {
name: "daemon",
description: "Manage the nerve background daemon",
},
subCommands: {
start: daemonStartCommand,
stop: stopCommand,
status: statusCommand,
restart: daemonRestartCommand,
logs: logsCommand,
},
});
+17
View File
@@ -0,0 +1,17 @@
import { defineCommand } from "citty";
import { runForegroundKernelSession } from "../run-foreground-kernel.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot } from "../workspace.js";
export const devCommand = defineCommand({
meta: {
name: "dev",
description: "Run the nerve kernel in the foreground (development mode)",
},
async run() {
const nerveRoot = getNerveRoot();
const { createKernel } = await loadDaemonModule(nerveRoot);
await runForegroundKernelSession(nerveRoot, createKernel);
},
});
+303 -54
View File
@@ -1,5 +1,7 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { execFile, spawn } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { defineCommand } from "citty";
@@ -19,6 +21,31 @@ reflexes:
interval: 10s
`;
const BIOME_JSON = `{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noConsole": "error"
}
}
}
}
`;
const PACKAGE_JSON = `{
"name": "my-nerve-workspace",
"version": "0.0.1",
@@ -26,10 +53,15 @@ const PACKAGE_JSON = `{
"type": "module",
"dependencies": {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
"drizzle-orm": "latest"
},
"devDependencies": {
"@biomejs/biome": "latest",
"drizzle-kit": "latest"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}
`;
@@ -38,6 +70,8 @@ const GITIGNORE = `data/
node_modules/
`;
const execFileAsync = promisify(execFile);
const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const cpuUsage = sqliteTable("cpu_usage", {
@@ -48,9 +82,9 @@ export const cpuUsage = sqliteTable("cpu_usage", {
});
`;
const CPU_INDEX_TS = `import { cpus } from "node:os";
const CPU_INDEX_JS = `import { cpus } from "node:os";
export async function compute(): Promise<unknown> {
export async function compute() {
const cpuList = cpus();
let totalIdle = 0;
@@ -86,7 +120,6 @@ function writeFile(filePath: string, content: string): void {
}
async function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, { cwd, stdio: "inherit" });
child.on("close", (code) => {
@@ -97,27 +130,107 @@ async function runCommand(cmd: string, args: string[], cwd: string): Promise<voi
});
}
async function detectPackageManager(): Promise<{ cmd: string; args: string[] }> {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> {
for (const pm of ["pnpm", "yarn", "npm"]) {
try {
await execFileAsync(pm, ["--version"]);
const args = pm === "pnpm" ? ["install", "--no-cache"] : ["install"];
return { cmd: pm, args };
const installArgs = pm === "pnpm" ? ["install", "--no-cache"] : ["install"];
return { cmd: pm, installArgs };
} catch {
// not available, try next
}
}
return { cmd: "npm", args: ["install"] };
return { cmd: "npm", installArgs: ["install"] };
}
export const initCommand = defineCommand({
export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
export function validateWorkflowName(name: string): string | null {
if (name.length === 0) return "Workflow name must not be empty.";
if (name.length > 64) return "Workflow name must be 64 characters or fewer.";
if (!WORKFLOW_NAME_RE.test(name))
return "Workflow name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.";
return null;
}
export function buildWorkflowTemplate(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
const workflow: WorkflowDefinition = {
roles: {
main: {
async execute(prompt, ctx) {
ctx.log("${name} started");
// TODO: implement your role logic here
return { type: "done" };
},
},
},
moderate(thread, event) {
if (event.type === "thread_start") {
return { role: "main", prompt: {} };
}
return null; // workflow complete
},
};
export default workflow;
`;
}
const initWorkflowCommand = defineCommand({
meta: {
name: "init",
description: "Initialize the ~/.uncaged-nerve/ workspace",
name: "workflow",
description: "Scaffold a new workflow template in ~/.uncaged-nerve/workflows/<name>/",
},
args: {
name: {
type: "positional",
description: "Workflow name (must match the key in nerve.yaml workflows section)",
},
force: {
type: "boolean",
description: "Overwrite if the workflow directory already exists",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const workflowDir = join(nerveRoot, "workflows", args.name);
const nameError = validateWorkflowName(args.name);
if (nameError !== null) {
process.stderr.write(`❌ Invalid workflow name: ${nameError}\n`);
process.exit(1);
}
if (existsSync(workflowDir) && !args.force) {
process.stderr.write(
`⚠️ Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`,
);
process.exit(1);
}
mkdirSync(workflowDir, { recursive: true });
writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name));
process.stdout.write(`✅ Workflow scaffolded: ${workflowDir}/index.ts\n`);
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(" 1. Add to nerve.yaml:\n");
process.stdout.write(" workflows:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" concurrency: 1\n");
process.stdout.write(" overflow: drop\n");
process.stdout.write(` 2. Edit ${workflowDir}/index.ts to implement your roles.\n`);
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
},
});
const initWorkspaceCommand = defineCommand({
meta: {
name: "workspace",
description: "Initialize the ~/.uncaged-nerve/ workspace (default)",
},
args: {
force: {
@@ -127,44 +240,180 @@ export const initCommand = defineCommand({
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
if (existsSync(nerveRoot) && !args.force) {
process.stderr.write("⚠️ ~/.uncaged-nerve/ already exists. Use --force to reinitialize.\n");
process.exit(1);
}
mkdirSync(join(nerveRoot, "data"), { recursive: true });
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "migrations"), { recursive: true });
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS);
writeFile(join(nerveRoot, "senses", "cpu-usage", "index.ts"), CPU_INDEX_TS);
writeFile(
join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"),
CPU_MIGRATION_SQL,
);
process.stdout.write("Installing dependencies…\n");
try {
const { cmd, args } = await detectPackageManager();
await runCommand(cmd, args, nerveRoot);
} catch {
process.stdout.write("⚠️ Install failed — you may need to install dependencies manually.\n");
}
if (!existsSync(join(nerveRoot, ".git"))) {
try {
await runCommand("git", ["init"], nerveRoot);
} catch {
process.stdout.write("⚠️ git init failed — skipping.\n");
}
}
process.stdout.write(
"✅ Workspace created at ~/.uncaged-nerve/\n 1 example sense: cpu-usage\n Run `nerve start` to launch the daemon.\n",
);
await runInitWorkspace(args.force);
},
});
/** Verify built-in `node:sqlite` (Node.js ≥22.5) loads in a child process. */
async function verifyNodeSqlite(): Promise<boolean> {
try {
await execFileAsync(
"node",
[
"--input-type=module",
"-e",
"import { DatabaseSync } from 'node:sqlite'; new DatabaseSync(':memory:').exec('SELECT 1');",
],
{ timeout: 10_000 },
);
return true;
} catch {
return false;
}
}
function isNerveRootNonEmpty(nerveRoot: string): boolean {
if (!existsSync(nerveRoot)) return false;
return readdirSync(nerveRoot).length > 0;
}
async function runInitFromGit(url: string): Promise<void> {
const trimmed = url.trim();
if (trimmed.length === 0) {
process.stderr.write("❌ --from requires a non-empty git URL.\n");
process.exit(1);
}
const nerveRoot = getNerveRoot();
if (isNerveRootNonEmpty(nerveRoot)) {
process.stderr.write(
`${nerveRoot} already exists and is not empty. Remove it (or empty it) before using --from.\n`,
);
process.exit(1);
}
try {
await execFileAsync("git", ["--version"]);
} catch {
process.stderr.write("❌ git is not available. Install git and retry.\n");
process.exit(1);
}
try {
await execFileAsync("pnpm", ["--version"]);
} catch {
process.stderr.write("❌ pnpm is not available. Install pnpm and retry.\n");
process.exit(1);
}
process.stdout.write(`Cloning ${trimmed}${nerveRoot}\n`);
try {
await runCommand("git", ["clone", trimmed, nerveRoot], process.cwd());
} catch {
process.stderr.write("❌ git clone failed.\n");
process.exit(1);
}
if (!existsSync(join(nerveRoot, "nerve.yaml"))) {
process.stdout.write(`⚠️ ${join(nerveRoot, "nerve.yaml")} not found after clone.\n`);
}
if (!existsSync(join(nerveRoot, "package.json"))) {
process.stdout.write(`⚠️ ${join(nerveRoot, "package.json")} not found after clone.\n`);
}
process.stdout.write("Installing dependencies with pnpm …\n");
try {
await runCommand("pnpm", ["install", "--no-cache"], nerveRoot);
} catch {
process.stdout.write(
`⚠️ pnpm install failed. Try manually:\n cd ${nerveRoot} && pnpm install --no-cache\n`,
);
}
if (!(await verifyNodeSqlite())) {
process.stdout.write(
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
);
}
process.stdout.write(
`✅ Workspace cloned to ${nerveRoot}\n\n💡 Next steps:\n 1. Review nerve.yaml and install any missing tooling.\n 2. Run \`nerve start\` to launch the daemon.\n`,
);
}
async function runInitWorkspace(force: boolean): Promise<void> {
const nerveRoot = getNerveRoot();
if (existsSync(nerveRoot) && !force) {
process.stderr.write("⚠️ ~/.uncaged-nerve/ already exists. Use --force to reinitialize.\n");
process.exit(1);
}
mkdirSync(join(nerveRoot, "data"), { recursive: true });
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "migrations"), { recursive: true });
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS);
writeFile(join(nerveRoot, "senses", "cpu-usage", "index.js"), CPU_INDEX_JS);
writeFile(
join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"),
CPU_MIGRATION_SQL,
);
process.stdout.write("Installing dependencies…\n");
const { cmd, installArgs } = await detectPackageManager();
try {
await runCommand(cmd, installArgs, nerveRoot);
} catch {
process.stdout.write(
`⚠️ Install failed. Try manually:\n cd ${nerveRoot} && ${cmd} ${installArgs.join(" ")}\n`,
);
}
if (!(await verifyNodeSqlite())) {
process.stdout.write(
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
);
}
if (!existsSync(join(nerveRoot, ".git"))) {
try {
await runCommand("git", ["init"], nerveRoot);
await runCommand("git", ["add", "."], nerveRoot);
await runCommand("git", ["commit", "-m", "Initial nerve workspace"], nerveRoot);
} catch {
process.stdout.write("⚠️ git init failed — skipping.\n");
}
}
process.stdout.write(
"✅ Workspace created at ~/.uncaged-nerve/\n 1 example sense: cpu-usage\n Run `nerve start` to launch the daemon.\n",
);
}
export const initCommand = defineCommand({
meta: {
name: "init",
description:
"Initialize workspace (nerve init), clone from git (nerve init --from <url>), or scaffold templates (nerve init workflow <name>)",
},
args: {
force: {
type: "boolean",
description: "Reinitialize even if workspace already exists (preserves data/)",
default: false,
},
from: {
type: "string",
description: "Clone an existing git repo into ~/.uncaged-nerve instead of scaffolding",
required: false,
},
},
subCommands: {
workflow: initWorkflowCommand,
workspace: initWorkspaceCommand,
},
async run({ args }) {
if (args.from !== undefined) {
await runInitFromGit(String(args.from));
return;
}
await runInitWorkspace(args.force);
},
});
+197
View File
@@ -0,0 +1,197 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline";
import { defineCommand } from "citty";
import { getLogPath } from "../workspace.js";
export const DEFAULT_LOG_LINES = 50;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Read all lines from a file. Returns empty array if file does not exist.
*
* TODO: For tail mode (offset=0), avoid reading the whole file into memory by
* seeking to the last N bytes via createReadStream({ start: max(0, size - CHUNK) }).
*/
export async function readAllLines(filePath: string): Promise<string[]> {
if (!existsSync(filePath)) return [];
const lines: string[] = [];
const rl = createInterface({
input: createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const line of rl) {
lines.push(line);
}
return lines;
}
/**
* Slice a log line array respecting offset + limit semantics.
*
* When offset is 0 the function returns the *last* `limit` lines (tail mode).
* When offset > 0 it is treated as a 1-based line number and the slice starts
* there (for pagination of earlier pages from the tail).
*
* Returns the selected lines plus metadata used to build the footer.
*/
export type LogSlice = {
lines: string[];
total: number;
startLine: number; // 1-based, inclusive
endLine: number; // 1-based, inclusive
nextOffset: number | null; // null when no previous page exists
};
export function sliceLogs(allLines: string[], offset: number, limit: number): LogSlice {
const total = allLines.length;
if (total === 0) {
return { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
}
let start: number;
if (offset === 0) {
// Tail mode: last `limit` lines
start = Math.max(0, total - limit);
} else {
// offset is 1-based line number
start = Math.max(0, offset - 1);
}
const end = Math.min(start + limit, total);
const lines = allLines.slice(start, end);
const startLine = start + 1;
const endLine = end;
// nextOffset points to lines *before* current slice (earlier in file)
const nextOffset = start > 0 ? Math.max(1, startLine - limit) : null;
return { lines, total, startLine, endLine, nextOffset };
}
/**
* Build the footer string shown after the log lines.
*/
export function buildLogFooter(slice: LogSlice, nArg: number, logPath: string): string {
if (slice.total === 0) {
return "📭 Log file is empty.\n";
}
const rangeStr = `lines ${slice.startLine}-${slice.endLine} of ${slice.total}`;
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
if (slice.nextOffset !== null) {
footer += "⏩ Earlier lines available. Fetch previous page:\n";
footer += ` nerve logs --offset ${slice.nextOffset} -n ${nArg}\n`;
}
return footer;
}
// ---------------------------------------------------------------------------
// nerve logs
// ---------------------------------------------------------------------------
export const logsCommand = defineCommand({
meta: {
name: "logs",
description: "Show daemon log output",
},
args: {
n: {
type: "string",
description: `Number of lines to show (default: ${DEFAULT_LOG_LINES})`,
default: String(DEFAULT_LOG_LINES),
},
offset: {
type: "string",
description: "Start from line N (1-based, for pagination)",
default: "0",
},
follow: {
type: "boolean",
alias: "f",
description: "Stream new log lines in real time",
default: false,
},
},
async run({ args }) {
const logPath = getLogPath();
const nLines = Math.max(1, Number.parseInt(args.n, 10) || DEFAULT_LOG_LINES);
const rawOffset = Number.parseInt(args.offset, 10) || 0;
if (rawOffset < 0) {
process.stderr.write(`❌ --offset must be a non-negative integer, got: ${args.offset}\n`);
process.exit(1);
}
const offset = rawOffset;
if (!existsSync(logPath)) {
process.stderr.write(`❌ Log file not found: ${logPath}\n`);
process.stderr.write(" Has the daemon been started? Try: nerve start\n");
process.exit(1);
}
if (args.follow) {
await followLog(logPath, nLines);
return;
}
const allLines = await readAllLines(logPath);
const slice = sliceLogs(allLines, offset, nLines);
for (const line of slice.lines) {
process.stdout.write(`${line}\n`);
}
process.stdout.write(buildLogFooter(slice, nLines, logPath));
},
});
/**
* Stream new lines from a log file as they are appended.
* Shows the last `tailLines` lines first, then watches for new content.
*/
async function followLog(logPath: string, tailLines: number): Promise<void> {
const allLines = await readAllLines(logPath);
const initial = allLines.slice(Math.max(0, allLines.length - tailLines));
for (const line of initial) {
process.stdout.write(`${line}\n`);
}
let size = statSync(logPath).size;
process.stdout.write(`\n👁 Following ${logPath} — press Ctrl+C to stop\n`);
let stopped = false;
process.once("SIGINT", () => {
stopped = true;
});
while (!stopped) {
await sleep(300);
if (stopped) break;
try {
const newSize = statSync(logPath).size;
if (newSize < size) {
// Log rotation: file was truncated or replaced, read from the beginning
size = 0;
}
if (newSize <= size) continue;
const stream = createReadStream(logPath, { start: size, encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of rl) {
process.stdout.write(`${line}\n`);
}
size = newSize;
} catch {
stopped = true;
}
}
}
+276
View File
@@ -0,0 +1,276 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { type SenseInfo, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js";
import {
defaultPreviewSql,
formatRowsAsAlignedTable,
listTableSqlStatements,
openSenseDb,
parseSenseQueryArgs,
pickDefaultPreviewTable,
} from "../sense-sqlite.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
// ---------------------------------------------------------------------------
// Formatting helpers (exported for tests)
// ---------------------------------------------------------------------------
export function formatDuration(ms: number | null): string {
if (ms === null) return "—";
const totalSeconds = Math.floor(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return `${minutes}m ${seconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
export function formatSenseList(senses: SenseInfo[]): string {
if (senses.length === 0) {
return "📭 No senses registered in nerve.yaml.\n";
}
const lines: string[] = [`📡 Registered senses (${senses.length}):\n`];
for (const s of senses) {
lines.push(`\n ${s.name}\n`);
lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
const lastSignal =
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
lines.push(` last signal: ${lastSignal}\n`);
}
return lines.join("");
}
/** Build a SenseInfo list from nerve.yaml when daemon is not running. */
export function sensesFromConfig(configPath: string): SenseInfo[] {
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch {
return [];
}
const result = parseNerveConfig(raw);
if (!result.ok) return [];
return Object.entries(result.value.senses).map(([name, cfg]) => ({
name,
group: cfg.group,
throttle: cfg.throttle,
timeout: cfg.timeout,
lastSignalTimestamp: null,
}));
}
// ---------------------------------------------------------------------------
// nerve sense list
// ---------------------------------------------------------------------------
const senseListCommand = defineCommand({
meta: {
name: "list",
description: "List all registered senses and their status",
},
async run() {
if (!isRunning()) {
process.stderr.write(
"⚠️ Daemon is not running — showing static config only (no last signal time).\n\n",
);
const configPath = join(getNerveRoot(), "nerve.yaml");
const senses = sensesFromConfig(configPath);
process.stdout.write(formatSenseList(senses));
return;
}
const socketPath = getSocketPath();
let response: { ok: true; senses: SenseInfo[] } | { ok: false; error: string };
try {
response = await listSensesViaDaemon(socketPath);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Daemon error: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(formatSenseList(response.senses));
},
});
// ---------------------------------------------------------------------------
// nerve sense trigger <name>
// ---------------------------------------------------------------------------
const senseTriggerCommand = defineCommand({
meta: {
name: "trigger",
description: "Manually trigger a sense compute by sending an IPC message to the running daemon",
},
args: {
name: {
type: "positional",
description: "The sense name to trigger",
},
},
async run({ args }) {
if (!isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const socketPath = getSocketPath();
let response: { ok: true } | { ok: false; error: string };
try {
response = await triggerSenseViaDaemon(socketPath, args.name);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Daemon rejected trigger: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(`✅ Triggered sense "${args.name}" via daemon.\n`);
},
});
// ---------------------------------------------------------------------------
// nerve sense schema <name>
// ---------------------------------------------------------------------------
const senseSchemaCommand = defineCommand({
meta: {
name: "schema",
description: "Print CREATE TABLE statements from a sense SQLite database",
},
args: {
name: {
type: "positional",
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
},
json: {
type: "boolean",
description: "Print JSON array of CREATE TABLE SQL strings",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
let db: DatabaseSync | undefined;
try {
db = openSenseDb(nerveRoot, args.name);
const statements = listTableSqlStatements(db);
if (args.json) {
process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`);
} else if (statements.length === 0) {
process.stdout.write("(no tables)\n");
} else {
for (const sql of statements) {
process.stdout.write(`${sql};\n\n`);
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
} finally {
db?.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve sense query <name> [sql...]
// ---------------------------------------------------------------------------
const senseQueryCommand = defineCommand({
meta: {
name: "query",
description:
"Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name; multiple words are joined.",
},
args: {
name: {
type: "positional",
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
},
json: {
type: "boolean",
description: "Print result rows as JSON",
default: false,
},
},
async run({ args, rawArgs }) {
const nerveRoot = getNerveRoot();
let db: DatabaseSync | undefined;
try {
let parsed: { name: string; sql: string | undefined };
try {
parsed = parseSenseQueryArgs(rawArgs);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
}
db = openSenseDb(nerveRoot, args.name);
let sql = parsed.sql?.trim();
if (!sql) {
const table = pickDefaultPreviewTable(db);
if (table === null) {
process.stderr.write("❌ No tables found in database.\n");
process.exit(1);
} else {
sql = defaultPreviewSql(table);
}
}
const rawRows: unknown[] = db.prepare(sql).all();
const rows: Record<string, unknown>[] = rawRows.filter(isPlainRecord);
if (args.json) {
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
} else {
process.stdout.write(formatRowsAsAlignedTable(rows));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
} finally {
db?.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve sense (parent command)
// ---------------------------------------------------------------------------
export const senseCommand = defineCommand({
meta: {
name: "sense",
description: "Interact with sense computes",
},
subCommands: {
list: senseListCommand,
trigger: senseTriggerCommand,
schema: senseSchemaCommand,
query: senseQueryCommand,
},
});
+73 -94
View File
@@ -1,78 +1,59 @@
import { createWriteStream } from "node:fs";
import { readFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { createWriteStream, existsSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { createKernel } from "@uncaged/nerve-daemon";
import { defineCommand } from "citty";
import { getLogPath, getNerveRoot, isRunning, readPidFile, writePidFile } from "../workspace.js";
import {
getLogPath,
getNerveRoot,
getSocketPath,
isRunning,
readPidFile,
removePidFile,
writePidFile,
} from "../workspace.js";
function readConfig(nerveRoot: string): ReturnType<typeof parseNerveConfig> {
const configPath = join(nerveRoot, "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: new Error(`❌ Cannot read ${configPath}: ${msg}`) };
}
return parseNerveConfig(raw);
function waitForSocket(socketPath: string, timeoutMs = 5000, intervalMs = 200): Promise<boolean> {
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
const check = (): void => {
if (existsSync(socketPath)) {
resolve(true);
} else if (Date.now() >= deadline) {
resolve(false);
} else {
setTimeout(check, intervalMs);
}
};
check();
});
}
async function runForeground(nerveRoot: string): Promise<void> {
const configResult = readConfig(nerveRoot);
if (!configResult.ok) {
process.stderr.write(`${configResult.error.message}\n`);
process.exit(1);
/** Path to the CLI entry script (used to locate dist/ next to bundled assets). */
function cliEntryScript(): string {
const here = fileURLToPath(import.meta.url);
const ext = here.endsWith(".ts") ? ".ts" : ".js";
const candidates = [join(dirname(here), `cli${ext}`), join(dirname(here), "..", `cli${ext}`)];
const cliPath = candidates.find((p) => existsSync(p));
if (!cliPath) {
throw new Error(`CLI entry not found (searched: ${candidates.join(", ")})`);
}
return cliPath;
}
const config = configResult.value;
const kernel = createKernel(config, nerveRoot);
const senseNames = Object.keys(config.senses);
const groups = [...kernel.groups];
process.stdout.write(
`✅ Nerve starting — ${senseNames.length} sense(s), ${groups.length} group(s)\n`,
function daemonBootstrapScript(): string {
const cliPath = cliEntryScript();
const dir = dirname(cliPath);
const bootstrapJs = join(dir, "daemon-bootstrap.js");
if (existsSync(bootstrapJs)) {
return bootstrapJs;
}
throw new Error(
`daemon-bootstrap.js not found next to CLI at ${bootstrapJs}. Build the CLI package (e.g. \`pnpm --filter @uncaged/nerve-cli build\`) before using \`nerve daemon start\`.`,
);
for (const group of groups) {
const groupSenses = Object.entries(config.senses)
.filter(([, sc]) => sc.group === group)
.map(([name]) => name);
process.stdout.write(` group "${group}": ${groupSenses.join(", ")}\n`);
}
process.stdout.write(" Press Ctrl+C to stop.\n");
let shuttingDown = false;
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
process.stdout.write("\n[nerve] Shutting down…\n");
await kernel.stop();
process.exit(0);
}
process.on("SIGINT", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
process.on("SIGTERM", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
await kernel.ready;
}
async function runDaemon(nerveRoot: string): Promise<void> {
@@ -82,28 +63,24 @@ async function runDaemon(nerveRoot: string): Promise<void> {
process.exit(1);
}
const configResult = readConfig(nerveRoot);
if (!configResult.ok) {
process.stderr.write(`${configResult.error.message}\n`);
process.exit(1);
}
const logPath = getLogPath();
await mkdir(join(nerveRoot, "logs"), { recursive: true });
const { spawn } = await import("node:child_process");
const logStream = createWriteStream(logPath, { flags: "a" });
await new Promise<void>((resolve) => {
if (logStream.pending) logStream.once("open", () => resolve());
else resolve();
});
const selfPath = fileURLToPath(import.meta.url);
const bootstrapPath = daemonBootstrapScript();
const child = spawn(process.execPath, [selfPath, "start"], {
// After `open`, file-backed WriteStream has a numeric OS fd for spawn stdio; `@types/node` omits `fd` on this WriteStream alias.
const logFd = (logStream as unknown as { fd: number }).fd;
const child = spawn(process.execPath, [bootstrapPath], {
detached: true,
stdio: ["ignore", logStream.fd, logStream.fd],
env: { ...process.env, NERVE_DAEMON_MODE: "1" },
stdio: ["ignore", logFd, logFd],
env: { ...process.env, NERVE_ROOT: nerveRoot },
cwd: nerveRoot,
});
child.unref();
@@ -115,31 +92,33 @@ async function runDaemon(nerveRoot: string): Promise<void> {
}
writePidFile(pid);
const ready = await waitForSocket(getSocketPath(), 5000);
if (!ready || !isRunning()) {
removePidFile();
process.stderr.write(
`❌ Daemon process exited shortly after start. Check logs at:\n ${logPath}\n`,
);
process.exit(1);
}
process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`);
process.stdout.write(` Logs: ${logPath}\n`);
process.stdout.write(" Run `nerve stop` to stop.\n");
process.stdout.write(" Run `nerve daemon stop` (or `nerve stop`) to stop.\n");
}
export const startCommand = defineCommand({
/** Background daemon only — use `nerve dev` for foreground mode. */
export async function runDaemonStartCommand(): Promise<void> {
await runDaemon(getNerveRoot());
}
export const daemonStartCommand = defineCommand({
meta: {
name: "start",
description: "Start the nerve daemon",
description: "Start the nerve daemon in the background",
},
args: {
daemon: {
type: "boolean",
alias: "d",
description: "Run as background daemon",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
if (args.daemon) {
await runDaemon(nerveRoot);
} else {
await runForeground(nerveRoot);
}
async run() {
await runDaemonStartCommand();
},
});
+5 -1
View File
@@ -47,7 +47,11 @@ export const statusCommand = defineCommand({
return;
}
const pid = readPidFile() as number;
const pid = readPidFile();
if (pid === null) {
process.stdout.write("😴 Nerve daemon is not running.\n");
return;
}
const configPath = join(getNerveRoot(), "nerve.yaml");
let senseList: string[] = [];
+38 -33
View File
@@ -15,44 +15,49 @@ async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
return false;
}
/** Core stop logic — also used by `nerve daemon restart`. */
export async function runStopCommand(): Promise<void> {
const pid = readPidFile();
if (pid === null) {
process.stdout.write("⚠️ No PID file found — daemon may not be running.\n");
return;
}
if (!isRunning()) {
process.stdout.write("⚠️ Daemon is not running (stale PID file). Cleaning up.\n");
removePidFile();
return;
}
process.stdout.write(`Stopping nerve daemon (pid ${pid})…\n`);
try {
process.kill(pid, "SIGTERM");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to send SIGTERM: ${msg}\n`);
process.exit(1);
}
const graceful = await waitForExit(pid, 10_000);
if (!graceful) {
process.stdout.write("⚠️ Daemon did not exit in 10s — sending SIGKILL.\n");
try {
process.kill(pid, "SIGKILL");
} catch {
// already dead
}
}
removePidFile();
process.stdout.write("✅ Daemon stopped.\n");
}
export const stopCommand = defineCommand({
meta: {
name: "stop",
description: "Stop the nerve daemon",
},
async run() {
const pid = readPidFile();
if (pid === null) {
process.stdout.write("⚠️ No PID file found — daemon may not be running.\n");
return;
}
if (!isRunning()) {
process.stdout.write("⚠️ Daemon is not running (stale PID file). Cleaning up.\n");
removePidFile();
return;
}
process.stdout.write(`Stopping nerve daemon (pid ${pid})…\n`);
try {
process.kill(pid, "SIGTERM");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to send SIGTERM: ${msg}\n`);
process.exit(1);
}
const graceful = await waitForExit(pid, 10_000);
if (!graceful) {
process.stdout.write("⚠️ Daemon did not exit in 10s — sending SIGKILL.\n");
try {
process.kill(pid, "SIGKILL");
} catch {
// already dead
}
}
removePidFile();
process.stdout.write("✅ Daemon stopped.\n");
await runStopCommand();
},
});
+70
View File
@@ -0,0 +1,70 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { defineCommand } from "citty";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot } from "../workspace.js";
// ---------------------------------------------------------------------------
// nerve store archive
// ---------------------------------------------------------------------------
const storeArchiveCommand = defineCommand({
meta: {
name: "archive",
description:
"Export logs older than 30 days from logs.db to data/archive/logs/YYYY-MM-DD.jsonl and delete those rows (RFC-001 §5.4)",
},
args: {
vacuum: {
type: "boolean",
description: "Run SQLite VACUUM after archiving",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const dbPath = join(nerveRoot, "data", "logs.db");
if (!existsSync(dbPath)) {
process.stderr.write("❌ No data/logs.db found — start the daemon at least once.\n");
process.exit(1);
}
const { createLogStore } = await loadDaemonModule(nerveRoot);
const store = createLogStore(dbPath);
try {
const result = store.archiveLogs({ vacuum: args.vacuum });
if (result.days.length === 0) {
process.stdout.write(
"✅ Nothing to archive (no eligible UTC days beyond the 30-day window).\n",
);
} else {
process.stdout.write(`✅ Archived ${result.days.length} day(s):\n`);
for (const d of result.days) {
process.stdout.write(` ${d.day} rows=${d.rowCount} ${d.filePath}\n`);
}
}
if (result.vacuumed) {
process.stdout.write(" VACUUM completed.\n");
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve store
// ---------------------------------------------------------------------------
export const storeCommand = defineCommand({
meta: {
name: "store",
description: "Maintain local Nerve SQLite stores (log cold-archive, …)",
},
subCommands: {
archive: storeArchiveCommand,
},
});
+1 -1
View File
@@ -31,7 +31,7 @@ export const validateCommand = defineCommand({
const config = result.value;
const senseCount = Object.keys(config.senses).length;
const reflexCount = config.reflexes.length;
const workflowCount = config.workflows ? Object.keys(config.workflows).length : 0;
const workflowCount = Object.keys(config.workflows).length;
process.stdout.write(
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
+625
View File
@@ -0,0 +1,625 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { DaemonIpcTriggerResponse } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { stringify } from "yaml";
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
import { killWorkflowViaDaemon, triggerWorkflowViaDaemon } from "../daemon-client.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
export const DEFAULT_PAGE_SIZE = 20;
/** Default max characters for `nerve workflow thread` output (including run header). */
export const DEFAULT_THREAD_BUDGET_CHARS = 8000;
/** Max role-round rows read from SQLite per invocation (DESC by round). */
export const THREAD_ROUNDS_FETCH_LIMIT = 8192;
export function parseIntArg(raw: string, fallback: number): number {
const v = Number.parseInt(raw, 10);
return Number.isNaN(v) ? fallback : v;
}
export function getDbPath(): string {
return join(getNerveRoot(), "data", "logs.db");
}
export function formatTs(timestampMs: number): string {
return new Date(timestampMs).toISOString();
}
async function openStore(): Promise<LogStore> {
const nerveRoot = getNerveRoot();
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
process.stderr.write("❌ No logs.db found — has the daemon run yet?\n");
process.exit(1);
}
const { createLogStore } = await loadDaemonModule(nerveRoot);
return createLogStore(dbPath);
}
export function statusIcon(status: WorkflowRun["status"]): string {
switch (status) {
case "started":
return "▶";
case "queued":
return "⏳";
case "completed":
return "✅";
case "failed":
return "❌";
case "crashed":
return "💥";
case "dropped":
return "🗑";
case "interrupted":
return "⚠️";
case "killed":
return "🛑";
default: {
const _exhaustive: never = status;
return `?(${_exhaustive})`;
}
}
}
/**
* Retrieve all workflow runs from the store, sorted by timestamp descending (newest first).
* Delegates to the store's efficient SQL query on the workflow_runs table.
*/
export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | null): WorkflowRun[] {
return store.getAllWorkflowRuns(filterWorkflow);
}
/**
* Format a single workflow run as a single output line (no trailing newline in icon/fields).
*/
export function formatRunLine(run: WorkflowRun): string {
const icon = statusIcon(run.status);
const exitCodeStr = run.exitCode !== null ? ` exit_code=${run.exitCode}` : "";
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status}${exitCodeStr} timestamp=${formatTs(run.timestamp)}\n`;
}
/**
* Format a paginated list of workflow runs into output lines.
* Returns the lines to write and any pagination hint.
*/
export type ListOutput = {
lines: string[];
paginationHint: string | null;
};
export function buildListOutput(
runs: WorkflowRun[],
offset: number,
limit: number,
allFlag: boolean,
filterWorkflow: string | null,
): ListOutput {
const total = runs.length;
const page = runs.slice(offset, offset + limit);
const shown = page.length;
const remaining = total - offset - shown;
if (total === 0) {
const msg = allFlag
? "📭 No workflow runs found.\n"
: "📭 No active workflow runs. Use --all to include completed/failed runs.\n";
return { lines: [msg], paginationHint: null };
}
const lines: string[] = [];
lines.push(`📋 Workflow runs (${shown} of ${total} shown):\n`);
for (const run of page) {
lines.push(formatRunLine(run));
}
let paginationHint: string | null = null;
if (remaining > 0) {
const wfFlag = filterWorkflow !== null ? ` --workflow ${filterWorkflow}` : "";
const allFlagStr = allFlag ? " --all" : "";
paginationHint =
`\n⏩ ${remaining} more run(s) not shown. Fetch next page:\n` +
` nerve workflow list --offset ${offset + limit}${allFlagStr}${wfFlag}\n`;
}
return { lines, paginationHint };
}
/**
* Format the inspect output for a single run's log entries with pagination.
*/
export type InspectOutput = {
header: string[];
eventLines: string[];
paginationHint: string | null;
};
export function buildInspectOutput(
run: WorkflowRun,
allLogs: Array<{ timestamp: number; type: string; payload: string | null }>,
offset: number,
limit: number,
): InspectOutput {
const total = allLogs.length;
const page = allLogs.slice(offset, offset + limit);
const shown = page.length;
const remaining = total - offset - shown;
const header: string[] = [
`🔍 Workflow run: ${run.runId}\n`,
` workflow: ${run.workflow}\n`,
` status: ${run.status}\n`,
` timestamp: ${formatTs(run.timestamp)}\n`,
`\n📜 Thread events (${shown} of ${total}):\n`,
];
const eventLines: string[] = [];
if (total === 0) {
eventLines.push(" (no events recorded)\n");
} else {
for (const entry of page) {
const payloadStr =
entry.payload === null
? ""
: entry.payload.length <= 200
? ` payload=${entry.payload}`
: ` payload=${entry.payload.slice(0, 200)}`;
eventLines.push(` [${formatTs(entry.timestamp)}] type=${entry.type}${payloadStr}\n`);
}
}
let paginationHint: string | null = null;
if (remaining > 0) {
paginationHint =
`\n⏩ ${remaining} more event(s) not shown. Fetch next page:\n` +
` nerve workflow inspect ${run.runId} --offset ${offset + limit}\n`;
}
return { header, eventLines, paginationHint };
}
// ---------------------------------------------------------------------------
// nerve workflow thread <runId> — agent-oriented role rounds
// ---------------------------------------------------------------------------
export type PartitionedMessage = {
roleStr: string;
contentBody: string;
meta: Record<string, unknown>;
};
/**
* Extract display fields from a WorkflowMessage-shaped object.
* `role` and `content` are used for header/body; `meta` is serialized as YAML frontmatter.
*/
export function partitionWorkflowMessage(msg: {
role: string;
content: string;
meta: unknown;
timestamp: number;
}): PartitionedMessage {
const roleStr = msg.role;
const contentBody = msg.content;
const meta: Record<string, unknown> =
msg.meta !== null && msg.meta !== undefined && typeof msg.meta === "object"
? isPlainRecord(msg.meta)
? msg.meta
: (msg.meta as Record<string, unknown>)
: {};
return { roleStr, contentBody, meta };
}
/**
* One role round as plain text: header line, YAML frontmatter (meta only), body (content).
*/
export function formatThreadRoundBlock(row: ThreadRoundRow): string {
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
const yamlBlock =
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
return `[#${row.round} ${roleStr}] ${formatTs(row.timestamp)}\n---\n${yamlBlock}---\n${contentBody}\n\n`;
}
export type ThreadCommandOutput = {
lines: string[];
paginationHint: string | null;
};
function buildTruncatedSingleRound(
row: ThreadRoundRow,
remaining: number,
prefixLines: string[],
runId: string,
budgetFlag: string,
): ThreadCommandOutput {
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
const yamlBlock =
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
const header = `[#${row.round} ${roleStr}] ${formatTs(row.timestamp)}\n---\n${yamlBlock}---\n`;
const maxBody = Math.max(0, remaining - header.length - "[truncated]\n".length);
const truncated =
maxBody > 0 && contentBody.length > maxBody
? `${contentBody.slice(0, maxBody)}\n[truncated]\n`
: `${contentBody}\n[truncated]\n`;
const single = `${header + truncated}\n`;
const hintRound = row.round;
return {
lines: [...prefixLines, single],
paginationHint:
hintRound > 1
? `\n⏩ Older rounds exist. Fetch with:\n nerve workflow thread ${runId} --before ${String(hintRound)}${budgetFlag}\n`
: null,
};
}
/**
* Build stdout lines for `nerve workflow thread`: newest-first selection from
* `descRows` until `budgetChars` (including `prefixLines`), then chronological order.
*/
export function buildThreadCommandOutput(
prefixLines: string[],
descRows: ThreadRoundRow[],
budgetChars: number,
runId: string,
): ThreadCommandOutput {
const prefixText = prefixLines.join("");
let remaining = Math.max(0, budgetChars - prefixText.length);
const picked: ThreadRoundRow[] = [];
const budgetFlag =
budgetChars === DEFAULT_THREAD_BUDGET_CHARS ? "" : ` --budget ${String(budgetChars)}`;
for (const row of descRows) {
const block = formatThreadRoundBlock(row);
if (block.length <= remaining) {
picked.push(row);
remaining -= block.length;
continue;
}
if (picked.length === 0) {
return buildTruncatedSingleRound(row, remaining, prefixLines, runId, budgetFlag);
}
break;
}
const blocksAsc = picked.map(formatThreadRoundBlock).reverse();
const shownMinRound = picked.length === 0 ? null : Math.min(...picked.map((r) => r.round));
let paginationHint: string | null = null;
if (shownMinRound !== null && shownMinRound > 1) {
paginationHint = `\n⏩ Older rounds not shown. Fetch with:\n nerve workflow thread ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
}
return { lines: [...prefixLines, ...blocksAsc], paginationHint };
}
// ---------------------------------------------------------------------------
// nerve workflow list
// ---------------------------------------------------------------------------
const workflowListCommand = defineCommand({
meta: {
name: "list",
description: "List active (queued/started) workflow runs",
},
args: {
all: {
type: "boolean",
description: "Include completed/failed/crashed runs",
default: false,
},
workflow: {
type: "string",
description: "Filter by workflow name",
default: "",
},
limit: {
type: "string",
description: `Max runs to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N runs (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const filterWorkflow = args.workflow.length > 0 ? args.workflow : null;
const runs = args.all
? getAllWorkflowRuns(store, filterWorkflow)
: store.getActiveWorkflowRuns(filterWorkflow ?? undefined);
const { lines, paginationHint } = buildListOutput(
runs,
offset,
limit,
args.all,
filterWorkflow,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve workflow inspect <runId>
// ---------------------------------------------------------------------------
const workflowInspectCommand = defineCommand({
meta: {
name: "inspect",
description: "Show details and thread events for a workflow run",
},
args: {
runId: {
type: "positional",
description: "The run ID to inspect",
},
limit: {
type: "string",
description: `Max log entries to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N log entries (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const allLogs = store.query({ source: "workflow", refId: args.runId });
const { header, eventLines, paginationHint } = buildInspectOutput(
run,
allLogs,
offset,
limit,
);
for (const line of [...header, ...eventLines]) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve workflow thread <runId>
// ---------------------------------------------------------------------------
const workflowThreadCommand = defineCommand({
meta: {
name: "thread",
description: "Print role rounds for a workflow run (agent-oriented, budget-limited)",
},
args: {
runId: {
type: "positional",
description: "The run ID to dump role rounds for",
},
before: {
type: "string",
description:
"Exclusive upper bound on 1-based round index (use with hint from prior output to load older rounds)",
default: "0",
},
budget: {
type: "string",
description: `Max output characters including header (default: ${String(DEFAULT_THREAD_BUDGET_CHARS)})`,
default: String(DEFAULT_THREAD_BUDGET_CHARS),
},
},
async run({ args }) {
const store = await openStore();
try {
const before = Math.max(0, parseIntArg(args.before, 0));
const budgetChars = Math.max(1, parseIntArg(args.budget, DEFAULT_THREAD_BUDGET_CHARS));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const totalRoleRounds = store.getThreadRoundCount(args.runId);
if (totalRoleRounds === 0) {
process.stdout.write(
`🧵 Workflow thread: ${run.runId}\n workflow: ${run.workflow}\n status: ${run.status}\n\n📭 No role rounds recorded for this run.\n`,
);
return;
}
const descRows = store.getThreadRounds(args.runId, {
before,
limit: THREAD_ROUNDS_FETCH_LIMIT,
});
const prefixLines = [
"🧵 Role rounds (workflow thread)\n",
` runId: ${run.runId}\n`,
` workflow: ${run.workflow}\n`,
` status: ${run.status}\n`,
` rounds: ${String(totalRoleRounds)} role event(s) total\n\n`,
];
const { lines, paginationHint } = buildThreadCommandOutput(
prefixLines,
descRows,
budgetChars,
args.runId,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
if (descRows.length === 0 && before > 0) {
process.stdout.write(`\n📭 No rounds with index < ${String(before)}.\n`);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve workflow trigger <name>
// ---------------------------------------------------------------------------
const workflowTriggerCommand = defineCommand({
meta: {
name: "trigger",
description: "Manually trigger a workflow by sending an IPC message to the running daemon",
},
args: {
name: {
type: "positional",
description: "The workflow name to trigger",
},
payload: {
type: "string",
description:
'JSON with optional "prompt" (string), "maxRounds" (number), and "dryRun" (boolean) for the workflow run (default: {})',
default: "{}",
},
},
async run({ args }) {
let triggerPayload: unknown = {};
try {
triggerPayload = JSON.parse(args.payload) as unknown;
} catch {
process.stderr.write(`❌ --payload must be valid JSON. Got: ${args.payload}\n`);
process.exit(1);
}
let prompt = "";
let maxRounds = DEFAULT_ENGINE_MAX_ROUNDS;
let dryRun = false;
if (isPlainRecord(triggerPayload)) {
const p = triggerPayload;
if (typeof p.prompt === "string") prompt = p.prompt;
if (typeof p.maxRounds === "number") maxRounds = p.maxRounds;
if (typeof p.dryRun === "boolean") dryRun = p.dryRun;
}
if (!isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const socketPath = getSocketPath();
let response: DaemonIpcTriggerResponse;
try {
response = await triggerWorkflowViaDaemon(socketPath, args.name, prompt, maxRounds, dryRun);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Daemon rejected trigger: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(`✅ Triggered workflow "${args.name}" via daemon.\n`);
process.stdout.write("\n💡 Inspect active runs with: nerve workflow list\n");
},
});
// ---------------------------------------------------------------------------
// nerve workflow kill <runId>
// ---------------------------------------------------------------------------
const workflowKillCommand = defineCommand({
meta: {
name: "kill",
description: "Kill a running or queued workflow thread by runId",
},
args: {
runId: {
type: "positional",
description: "The run ID to kill",
},
},
async run({ args }) {
if (!isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const socketPath = getSocketPath();
let response: DaemonIpcTriggerResponse;
try {
response = await killWorkflowViaDaemon(socketPath, args.runId);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Kill failed: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(`✅ Kill signal sent for run "${args.runId}".\n`);
},
});
// ---------------------------------------------------------------------------
// nerve workflow (parent command)
// ---------------------------------------------------------------------------
export const workflowCommand = defineCommand({
meta: {
name: "workflow",
description: "Manage and inspect workflow runs",
},
subCommands: {
list: workflowListCommand,
inspect: workflowInspectCommand,
thread: workflowThreadCommand,
trigger: workflowTriggerCommand,
kill: workflowKillCommand,
},
});
+11
View File
@@ -0,0 +1,11 @@
import { runForegroundKernelSession } from "./run-foreground-kernel.js";
import { loadDaemonModule } from "./workspace-daemon.js";
const nerveRoot = process.env.NERVE_ROOT;
if (nerveRoot === undefined || nerveRoot.length === 0) {
process.stderr.write("[nerve] NERVE_ROOT environment variable is required.\n");
process.exit(1);
}
const { createKernel } = await loadDaemonModule(nerveRoot);
await runForegroundKernelSession(nerveRoot, createKernel);
+181
View File
@@ -0,0 +1,181 @@
/**
* Daemon IPC client — connects to the daemon's Unix socket and sends
* trigger-workflow or trigger-sense requests.
*
* Protocol: newline-delimited JSON (same as daemon-ipc.ts server side).
*/
import { connect } from "node:net";
import type { Socket } from "node:net";
import type {
DaemonIpcListSensesResponse,
DaemonIpcRequest,
DaemonIpcTriggerResponse,
SenseInfo,
} from "@uncaged/nerve-core";
import { isPlainRecord } from "@uncaged/nerve-core";
const CONNECT_TIMEOUT_MS = 3_000;
const RESPONSE_TIMEOUT_MS = 5_000;
export type { SenseInfo };
function isSenseInfo(value: unknown): value is SenseInfo {
if (!isPlainRecord(value)) return false;
return (
typeof value.name === "string" &&
typeof value.group === "string" &&
(value.throttle === null || typeof value.throttle === "number") &&
(value.timeout === null || typeof value.timeout === "number") &&
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
);
}
function parseDaemonResponse(line: string): DaemonIpcTriggerResponse {
try {
const obj: unknown = JSON.parse(line);
if (isPlainRecord(obj)) {
const r = obj;
if (r.ok === true) return { ok: true };
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
}
} catch {
// fall through
}
return { ok: false, error: `Unexpected daemon response: ${line}` };
}
function parseListSensesResponse(line: string): DaemonIpcListSensesResponse {
try {
const obj: unknown = JSON.parse(line);
if (isPlainRecord(obj)) {
const r = obj;
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
if (r.ok === true && Array.isArray(r.senses) && r.senses.every(isSenseInfo)) {
return { ok: true, senses: r.senses };
}
}
} catch {
// fall through
}
return { ok: false, error: `Unexpected daemon response: ${line}` };
}
/**
* Connect to the daemon socket, send one JSON request (newline-terminated),
* and resolve with the first non-empty line parsed by `parseFirstLine`.
*/
function sendAndReceive<T>(
socketPath: string,
message: DaemonIpcRequest,
parseFirstLine: (trimmed: string) => T,
responseTimeoutMs: number = RESPONSE_TIMEOUT_MS,
): Promise<T> {
return new Promise((resolve, reject) => {
let socket: Socket | null = null;
let settled = false;
function settle(result: T | Error): void {
if (settled) return;
settled = true;
if (socket !== null) {
socket.destroy();
socket = null;
}
if (result instanceof Error) {
reject(result);
} else {
resolve(result);
}
}
const connectTimer = setTimeout(() => {
settle(new Error(`Timed out connecting to daemon socket: ${socketPath}`));
}, CONNECT_TIMEOUT_MS);
socket = connect(socketPath, () => {
clearTimeout(connectTimer);
const responseTimer = setTimeout(() => {
settle(new Error("Timed out waiting for daemon response"));
}, responseTimeoutMs);
let buf = "";
socket?.on("data", (chunk: Buffer) => {
buf += chunk.toString("utf8");
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
clearTimeout(responseTimer);
settle(parseFirstLine(trimmed));
return;
}
});
const msg = `${JSON.stringify(message)}\n`;
socket?.write(msg);
});
socket.on("error", (err) => {
clearTimeout(connectTimer);
settle(new Error(`Cannot connect to daemon: ${err.message}`));
});
});
}
/**
* Send a trigger-workflow message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors.
*/
export function triggerWorkflowViaDaemon(
socketPath: string,
workflow: string,
prompt: string,
maxRounds: number,
dryRun = false,
): Promise<DaemonIpcTriggerResponse> {
const message: DaemonIpcRequest = {
type: "trigger-workflow",
workflow,
prompt,
maxRounds,
dryRun,
};
return sendAndReceive(socketPath, message, parseDaemonResponse);
}
/**
* Send a trigger-sense message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors.
*/
export function triggerSenseViaDaemon(
socketPath: string,
sense: string,
): Promise<DaemonIpcTriggerResponse> {
const message: DaemonIpcRequest = { type: "trigger-sense", sense };
return sendAndReceive(socketPath, message, parseDaemonResponse);
}
/**
* Send a list-senses message to the running daemon via its Unix socket.
* Resolves with the list of registered senses or rejects on connection/timeout errors.
*/
export function listSensesViaDaemon(socketPath: string): Promise<DaemonIpcListSensesResponse> {
const message: DaemonIpcRequest = { type: "list-senses" };
return sendAndReceive(socketPath, message, parseListSensesResponse);
}
/**
* Send a kill-workflow message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors.
*/
export function killWorkflowViaDaemon(
socketPath: string,
runId: string,
): Promise<DaemonIpcTriggerResponse> {
const message: DaemonIpcRequest = { type: "kill-workflow", runId };
return sendAndReceive(socketPath, message, parseDaemonResponse);
}
+5 -2
View File
@@ -8,5 +8,8 @@ export {
isRunning,
} from "./workspace.js";
export { createKernel } from "@uncaged/nerve-daemon";
export type { Kernel } from "@uncaged/nerve-daemon";
export {
assertWorkspaceDaemonInstalled,
getDaemonEntryPath,
loadDaemonModule,
} from "./workspace-daemon.js";
+88
View File
@@ -0,0 +1,88 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { getSocketPath } from "./workspace.js";
export type CreateKernelFn = (
config: NerveConfig,
nerveRoot: string,
opts: { enableFileWatcher: boolean; ipcSocketPath: string },
) => {
groups: Set<string>;
ready: Promise<void>;
stop: () => Promise<void>;
};
function readConfig(nerveRoot: string): ReturnType<typeof parseNerveConfig> {
const configPath = join(nerveRoot, "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: new Error(`❌ Cannot read ${configPath}: ${msg}`) };
}
return parseNerveConfig(raw);
}
export async function runForegroundKernelSession(
nerveRoot: string,
createKernel: CreateKernelFn,
): Promise<void> {
const configResult = readConfig(nerveRoot);
if (!configResult.ok) {
process.stderr.write(`${configResult.error.message}\n`);
process.exit(1);
}
const config = configResult.value;
const kernel = createKernel(config, nerveRoot, {
enableFileWatcher: true,
ipcSocketPath: getSocketPath(),
});
const senseNames = Object.keys(config.senses);
const groups = [...kernel.groups];
process.stdout.write(
`✅ Nerve starting — ${senseNames.length} sense(s), ${groups.length} group(s)\n`,
);
for (const group of groups) {
const groupSenses = Object.entries(config.senses)
.filter(([, sc]) => sc.group === group)
.map(([name]) => name);
process.stdout.write(` group "${group}": ${groupSenses.join(", ")}\n`);
}
process.stdout.write(" Press Ctrl+C to stop.\n");
let shuttingDown = false;
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
process.stdout.write("\n[nerve] Shutting down…\n");
await kernel.stop();
process.exit(0);
}
process.on("SIGINT", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
process.on("SIGTERM", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
await kernel.ready;
}
+127
View File
@@ -0,0 +1,127 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
/** SQLite path for a sense under the nerve workspace root. */
export function senseDbPath(nerveRoot: string, senseName: string): string {
return join(nerveRoot, "data", "senses", `${senseName}.db`);
}
export function assertSenseDbExists(nerveRoot: string, senseName: string): string {
const path = senseDbPath(nerveRoot, senseName);
if (!existsSync(path)) {
throw new Error(`No database at ${path}`);
}
return path;
}
/** Open a sense SQLite database in readonly mode using node:sqlite. */
export function openSenseDb(nerveRoot: string, senseName: string): DatabaseSync {
const path = assertSenseDbExists(nerveRoot, senseName);
return new DatabaseSync(path, { readOnly: true });
}
/** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */
export function listTableSqlStatements(db: DatabaseSync): string[] {
const rows = db
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`,
)
.all() as { sql: string }[];
return rows.map((r) => r.sql);
}
/**
* Table used for `nerve sense query <name>` with no SQL.
* Prefers real data tables over `_migrations`, then lexicographic by name.
*/
export function pickDefaultPreviewTable(db: DatabaseSync): string | null {
const row = db
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND sql IS NOT NULL
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
ORDER BY
CASE WHEN name = '_migrations' THEN 1 ELSE 0 END,
name
LIMIT 1`,
)
.get() as { name: string } | undefined;
return row?.name ?? null;
}
export function defaultPreviewSql(table: string): string {
return `SELECT * FROM "${table.replace(/"/g, '""')}" ORDER BY rowid DESC LIMIT 10`;
}
/** Parse sense name and optional SQL from subcommand raw argv (flags stripped). */
export function parseSenseQueryArgs(rawArgs: string[]): { name: string; sql: string | undefined } {
const pos: string[] = [];
for (let i = 0; i < rawArgs.length; i++) {
const a = rawArgs[i];
if (a === "--json" || a === "--no-json") continue;
if (a.startsWith("-")) {
const eq = a.indexOf("=");
if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) {
i += 1;
}
continue;
}
pos.push(a);
}
if (pos.length < 1) {
throw new Error("Missing sense name");
}
const name = pos[0];
const sql = pos.length > 1 ? pos.slice(1).join(" ") : undefined;
return { name, sql };
}
function stringifyCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "bigint") return value.toString();
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (typeof value === "string") return value;
if (Buffer.isBuffer(value)) return value.toString("hex");
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
/** Collect column keys in stable order (first row keys, then any extras). */
export function collectColumnKeys(rows: Record<string, unknown>[]): string[] {
const keys: string[] = [];
const seen = new Set<string>();
for (const row of rows) {
for (const k of Object.keys(row)) {
if (!seen.has(k)) {
seen.add(k);
keys.push(k);
}
}
}
return keys;
}
const MAX_CELL = 64;
function truncate(s: string): string {
if (s.length <= MAX_CELL) return s;
return `${s.slice(0, MAX_CELL - 1)}`;
}
/** Plain aligned table for terminal output. */
export function formatRowsAsAlignedTable(rows: Record<string, unknown>[]): string {
if (rows.length === 0) {
return "(0 rows)\n";
}
const cols = collectColumnKeys(rows);
const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c]))));
const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length)));
const sep = widths.map((w) => "-".repeat(w)).join("-+-");
const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | ");
const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n");
return `${header}\n${sep}\n${body}\n`;
}
+51
View File
@@ -0,0 +1,51 @@
import { existsSync } from "node:fs";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { LogStore } from "@uncaged/nerve-store";
export function getDaemonEntryPath(nerveRoot: string): string | undefined {
const pkgPath = join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", "package.json");
if (!existsSync(pkgPath)) return undefined;
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { main?: string };
const main = pkg.main ?? "dist/index.js";
return join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", main);
} catch {
return join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", "dist", "index.js");
}
}
export function assertWorkspaceDaemonInstalled(nerveRoot: string): string {
const entry = getDaemonEntryPath(nerveRoot);
if (!entry || !existsSync(entry)) {
throw new Error(
`@uncaged/nerve-daemon is not installed under ${nerveRoot}/node_modules/. Run \`nerve init\` (or \`nerve init --force\`) to install workspace dependencies.`,
);
}
return entry;
}
/** Loaded from ~/.uncaged-nerve/node_modules at runtime. */
export type DaemonModule = {
createKernel: (
config: NerveConfig,
nerveRoot: string,
options: { enableFileWatcher: boolean; ipcSocketPath: string },
) => {
groups: Set<string>;
ready: Promise<void>;
stop: () => Promise<void>;
};
createLogStore: (dbPath: string) => LogStore;
};
export async function loadDaemonModule(nerveRoot: string): Promise<DaemonModule> {
const entry = assertWorkspaceDaemonInstalled(nerveRoot);
const url = pathToFileURL(entry).href;
// Dynamic import return type is module-specific; narrow at this workspace boundary.
return import(url) as Promise<DaemonModule>;
}
+4
View File
@@ -10,6 +10,10 @@ export function getPidPath(): string {
return join(getNerveRoot(), "nerve.pid");
}
export function getSocketPath(): string {
return join(getNerveRoot(), "nerve.sock");
}
export function getLogPath(): string {
return join(getNerveRoot(), "logs", "nerve.log");
}
+2 -1
View File
@@ -6,5 +6,6 @@
"composite": false,
"types": ["node"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/__tests__"]
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/cli.ts"],
format: ["esm"],
dts: true,
clean: true,
});
+65
View File
@@ -0,0 +1,65 @@
# @uncaged/nerve-core
Shared types and configuration parser for the [nerve](../../README.md) observation engine.
## What's Inside
- **Type definitions** — `Signal`, `SenseConfig`, `SenseInfo`, `SenseReflexConfig`, `ReflexConfig` (sense-only), `WorkflowConfig`, `NerveConfig`, and related types
- **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (rejects reflex entries that declare a `workflow` key; reflexes only schedule senses)
- **Sense → workflow routing** — `parseSenseWorkflowDirective`, `routeSenseComputeOutput`, and types `ParsedSenseWorkflowDirective`, `SenseComputeRoute`
- **Daemon IPC protocol** — request/response types (`DaemonIpcRequest`, `DaemonIpcResponse`, …) and `parseDaemonIpcRequest` for newline-delimited JSON on the CLI ↔ daemon socket
- **Workflow automaton types** — `START` / `END` sentinel constants, `WorkflowMessage`, `StartStep`, `RoleStep`, `ModeratorContext` (`start` + `steps`; empty `steps` on first moderator call), `Moderator` (single `context` argument), `WorkflowDefinition`, `Role`, `SenseResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS`
- **Result type** — `Result<T>` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions for parse paths)
## Usage
```typescript
import { parseNerveConfig, ok, err } from "@uncaged/nerve-core";
import type { NerveConfig, Signal, Result } from "@uncaged/nerve-core";
const result: Result<NerveConfig> = parseNerveConfig(yamlString);
if (result.ok) {
console.log(result.value.senses);
}
```
### Sense return → signal vs workflow
```typescript
import { parseSenseWorkflowDirective, routeSenseComputeOutput } from "@uncaged/nerve-core";
const directive = parseSenseWorkflowDirective("my-workflow|8|Hello from sense");
if (directive.ok) {
console.log(directive.value.workflowName, directive.value.maxRounds, directive.value.prompt);
}
const route = routeSenseComputeOutput({
metric: 42,
workflow: "my-workflow|8|Run now",
});
if (route.kind === "launch") {
// engine starts workflow; no Signal to the bus for this return
console.log(route.launch);
} else {
// normal signal with payload
console.log(route.payload);
}
```
## Duration Format
Config fields like `throttle`, `timeout`, and `interval` accept human-readable durations:
- `5s` — 5 seconds
- `10m` — 10 minutes
- `1h` — 1 hour
## Install
```bash
pnpm add @uncaged/nerve-core
```
## License
MIT
+8 -3
View File
@@ -1,18 +1,23 @@
{
"name": "@uncaged/nerve-core",
"version": "0.0.1",
"private": true,
"version": "0.4.0",
"type": "module",
"main": "dist/index.js",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup",
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run"
},
"dependencies": {
"yaml": "^2.8.3"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"vitest": "^4.1.5"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
+15 -22
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseNerveConfig } from "../config.js";
import { parseNerveConfig } from "../parse-nerve-config.js";
const VALID_CONFIG = `
senses:
@@ -18,9 +18,6 @@ reflexes:
- sense: memory
on:
- high_usage
- workflow: alert
on:
- cpu
workflows:
alert:
@@ -48,12 +45,12 @@ describe("parseNerveConfig", () => {
timeout: 10_000,
gracePeriod: 3000,
});
expect(result.value.reflexes).toHaveLength(3);
expect(result.value.reflexes).toHaveLength(2);
expect(result.value.reflexes[0]).toEqual({
kind: "sense",
sense: "cpu",
interval: 30_000,
on: null,
on: [],
});
expect(result.value.reflexes[1]).toEqual({
kind: "sense",
@@ -61,12 +58,7 @@ describe("parseNerveConfig", () => {
interval: null,
on: ["high_usage"],
});
expect(result.value.reflexes[2]).toEqual({
kind: "workflow",
workflow: "alert",
on: ["cpu"],
});
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 2,
overflow: "queue",
maxQueue: 10,
@@ -93,11 +85,12 @@ senses:
group: system
reflexes:
- sense: cpu
interval: 1s
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows).toBeNull();
expect(result.value.workflows).toEqual({});
});
it("sense config has null for omitted throttle/timeout/gracePeriod", () => {
@@ -150,11 +143,11 @@ workflows:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 1,
overflow: "drop",
});
expect("maxQueue" in (result.value.workflows?.alert ?? {})).toBe(false);
expect("maxQueue" in result.value.workflows.alert).toBe(false);
});
it("overflow: queue defaults maxQueue to 100", () => {
@@ -171,7 +164,7 @@ workflows:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 1,
overflow: "queue",
maxQueue: 100,
@@ -201,7 +194,7 @@ reflexes:
expect(result.error.message).toMatch(/disk.*not found in senses/);
});
it("returns error when workflow reflex references a non-existent workflow", () => {
it("returns error when reflex uses unsupported workflow field", () => {
const yaml = `
senses:
cpu:
@@ -214,10 +207,10 @@ reflexes:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/missing_wf.*not found in workflows/);
expect(result.error.message).toMatch(/workflow.*not supported/);
});
it("returns error when workflow reflex references non-existent workflow (with workflows defined)", () => {
it("returns error when reflex uses unsupported workflow field (with workflows defined)", () => {
const yaml = `
senses:
cpu:
@@ -234,7 +227,7 @@ workflows:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/unknown.*not found in workflows/);
expect(result.error.message).toMatch(/workflow.*not supported/);
});
it("returns error for invalid throttle format", () => {
@@ -362,7 +355,7 @@ reflexes:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/cannot have both/);
expect(result.error.message).toMatch(/workflow.*not supported/);
});
it("returns error when reflex has neither sense nor workflow", () => {
@@ -376,7 +369,7 @@ reflexes:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/must have either/);
expect(result.error.message).toMatch(/must include "sense"/);
});
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { parseDaemonIpcRequest } from "../daemon-ipc-protocol.js";
describe("parseDaemonIpcRequest", () => {
it("parses trigger-workflow", () => {
expect(
parseDaemonIpcRequest(
JSON.stringify({
type: "trigger-workflow",
workflow: "wf",
prompt: "go",
maxRounds: 3,
}),
),
).toEqual({
type: "trigger-workflow",
workflow: "wf",
prompt: "go",
maxRounds: 3,
dryRun: false,
});
});
it("parses trigger-workflow with dryRun true", () => {
expect(
parseDaemonIpcRequest(
JSON.stringify({
type: "trigger-workflow",
workflow: "wf",
prompt: "go",
maxRounds: 3,
dryRun: true,
}),
),
).toEqual({
type: "trigger-workflow",
workflow: "wf",
prompt: "go",
maxRounds: 3,
dryRun: true,
});
});
it("rejects trigger-workflow with empty workflow", () => {
expect(
parseDaemonIpcRequest(
JSON.stringify({
type: "trigger-workflow",
workflow: "",
prompt: "",
maxRounds: 1,
}),
),
).toBeNull();
});
it("parses trigger-sense and list-senses", () => {
expect(parseDaemonIpcRequest(JSON.stringify({ type: "trigger-sense", sense: "x" }))).toEqual({
type: "trigger-sense",
sense: "x",
});
expect(parseDaemonIpcRequest(JSON.stringify({ type: "list-senses" }))).toEqual({
type: "list-senses",
});
});
it("returns null for invalid JSON or unknown type", () => {
expect(parseDaemonIpcRequest("not json")).toBeNull();
expect(parseDaemonIpcRequest(JSON.stringify({ type: "nope" }))).toBeNull();
});
});
+30 -305
View File
@@ -1,312 +1,37 @@
import { parse } from "yaml";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./types.js";
const DURATION_RE = /^(\d+)([smh])$/;
const DURATION_MULTIPLIERS: Record<string, number> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
export type SenseConfig = {
group: string;
throttle: number | null;
timeout: number | null;
gracePeriod: number | null;
};
function parseDurationToMs(value: string): number | null {
const match = DURATION_RE.exec(value);
if (!match) return null;
return Number(match[1]) * DURATION_MULTIPLIERS[match[2]];
}
export type SenseReflexConfig = {
kind: "sense";
sense: string;
interval: number | null;
on: string[];
};
function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value);
}
/** Reflexes only schedule Senses; workflow launches come from Sense return values. */
export type ReflexConfig = SenseReflexConfig;
function parseDurationField(field: unknown, label: string): Result<number | null> {
if (field === undefined || field === null) return ok(null);
if (typeof field !== "string") {
return err(
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
);
}
const ms = parseDurationToMs(field);
if (ms === null) {
return err(
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
);
}
return ok(ms);
}
export type DropOverflowConfig = {
concurrency: number;
overflow: "drop";
};
function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return err(new Error(`senses.${name}: must be an object`));
}
export type QueueOverflowConfig = {
concurrency: number;
overflow: "queue";
maxQueue: number;
};
const obj = raw as Record<string, unknown>;
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
if (typeof obj.group !== "string" || obj.group.trim() === "") {
return err(new Error(`senses.${name}.group: required string`));
}
if (!isValidGroupName(obj.group)) {
return err(
new Error(
`senses.${name}.group: invalid name "${obj.group}" (only alphanumeric, underscore, hyphen allowed)`,
),
);
}
const throttleResult = parseDurationField(obj.throttle, `senses.${name}.throttle`);
if (!throttleResult.ok) return throttleResult;
const timeoutResult = parseDurationField(obj.timeout, `senses.${name}.timeout`);
if (!timeoutResult.ok) return timeoutResult;
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
if (!graceResult.ok) return graceResult;
return ok({
group: obj.group,
throttle: throttleResult.value,
timeout: timeoutResult.value,
gracePeriod: graceResult.value,
});
}
function parseOnField(index: number, obj: Record<string, unknown>): Result<string[] | null> {
if (obj.on === undefined || obj.on === null) return ok(null);
if (!Array.isArray(obj.on) || !obj.on.every((item) => typeof item === "string")) {
return err(new Error(`reflexes[${index}].on: must be an array of strings`));
}
return ok(obj.on as string[]);
}
function parseSenseReflex(
index: number,
obj: Record<string, unknown>,
senseNames: Set<string>,
on: string[] | null,
): Result<ReflexConfig> {
if (typeof obj.sense !== "string") {
return err(new Error(`reflexes[${index}].sense: must be a string`));
}
if (!senseNames.has(obj.sense)) {
return err(new Error(`reflexes[${index}].sense: "${obj.sense}" not found in senses`));
}
const intervalResult = parseDurationField(obj.interval, `reflexes[${index}].interval`);
if (!intervalResult.ok) return intervalResult;
if (intervalResult.value === null && on !== null && on.length === 0) {
return err(
new Error(`reflexes[${index}]: sense reflex must have at least one of "interval" or "on"`),
);
}
return ok({
kind: "sense" as const,
sense: obj.sense,
interval: intervalResult.value,
on,
});
}
function parseWorkflowReflex(
index: number,
obj: Record<string, unknown>,
on: string[] | null,
): Result<ReflexConfig> {
if (typeof obj.workflow !== "string") {
return err(new Error(`reflexes[${index}].workflow: must be a string`));
}
if (obj.interval !== undefined) {
return err(
new Error(`reflexes[${index}]: workflow reflex does not support "interval" (use "on")`),
);
}
return ok({
kind: "workflow" as const,
workflow: obj.workflow,
on,
});
}
function validateReflexConfig(
index: number,
raw: unknown,
senseNames: Set<string>,
): Result<ReflexConfig> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return err(new Error(`reflexes[${index}]: must be an object`));
}
const obj = raw as Record<string, unknown>;
const hasSense = obj.sense !== undefined;
const hasWorkflow = obj.workflow !== undefined;
if (hasSense && hasWorkflow) {
return err(new Error(`reflexes[${index}]: cannot have both "sense" and "workflow"`));
}
if (!hasSense && !hasWorkflow) {
return err(new Error(`reflexes[${index}]: must have either "sense" or "workflow"`));
}
const onResult = parseOnField(index, obj);
if (!onResult.ok) return onResult;
if (hasSense) {
return parseSenseReflex(index, obj, senseNames, onResult.value);
}
return parseWorkflowReflex(index, obj, onResult.value);
}
function validateWorkflowConfig(name: string, raw: unknown): Result<WorkflowConfig> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return err(new Error(`workflows.${name}: must be an object`));
}
const obj = raw as Record<string, unknown>;
if (
typeof obj.concurrency !== "number" ||
!Number.isInteger(obj.concurrency) ||
obj.concurrency < 1
) {
return err(new Error(`workflows.${name}.concurrency: must be a positive integer`));
}
if (obj.overflow !== "drop" && obj.overflow !== "queue") {
return err(new Error(`workflows.${name}.overflow: must be "drop" or "queue"`));
}
if (obj.overflow === "drop") {
if (obj.max_queue !== undefined && obj.max_queue !== null) {
return err(new Error(`workflows.${name}: max_queue is not allowed with overflow "drop"`));
}
return ok({
concurrency: obj.concurrency,
overflow: "drop" as const,
});
}
// overflow: "queue"
let maxQueue = 100; // default
if (obj.max_queue !== undefined && obj.max_queue !== null) {
if (
typeof obj.max_queue !== "number" ||
!Number.isInteger(obj.max_queue) ||
obj.max_queue < 1
) {
return err(new Error(`workflows.${name}.max_queue: must be a positive integer`));
}
maxQueue = obj.max_queue;
}
return ok({
concurrency: obj.concurrency,
overflow: "queue" as const,
maxQueue,
});
}
function parseSenses(
obj: Record<string, unknown>,
): Result<{ senses: Record<string, SenseConfig>; senseNames: Set<string> }> {
if (obj.senses === null || typeof obj.senses !== "object" || Array.isArray(obj.senses)) {
return err(new Error("senses: required object"));
}
const sensesRaw = obj.senses as Record<string, unknown>;
const senses: Record<string, SenseConfig> = {};
const senseNames = new Set(Object.keys(sensesRaw));
for (const [name, senseRaw] of Object.entries(sensesRaw)) {
const result = validateSenseConfig(name, senseRaw);
if (!result.ok) return result;
senses[name] = result.value;
}
return ok({ senses, senseNames });
}
function parseReflexes(
obj: Record<string, unknown>,
senseNames: Set<string>,
): Result<ReflexConfig[]> {
if (!Array.isArray(obj.reflexes)) {
return err(new Error("reflexes: required array"));
}
const reflexes: ReflexConfig[] = [];
for (let i = 0; i < obj.reflexes.length; i++) {
const result = validateReflexConfig(i, obj.reflexes[i], senseNames);
if (!result.ok) return result;
reflexes.push(result.value);
}
return ok(reflexes);
}
function parseWorkflows(
obj: Record<string, unknown>,
): Result<Record<string, WorkflowConfig> | null> {
if (obj.workflows === undefined || obj.workflows === null) return ok(null);
if (typeof obj.workflows !== "object" || Array.isArray(obj.workflows)) {
return err(new Error("workflows: must be an object if provided"));
}
const workflowsRaw = obj.workflows as Record<string, unknown>;
const workflows: Record<string, WorkflowConfig> = {};
for (const [name, wfRaw] of Object.entries(workflowsRaw)) {
const result = validateWorkflowConfig(name, wfRaw);
if (!result.ok) return result;
workflows[name] = result.value;
}
return ok(workflows);
}
export function parseNerveConfig(raw: string): Result<NerveConfig> {
let parsed: unknown;
try {
parsed = parse(raw);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(new Error(`YAML parse error: ${message}`));
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return err(new Error("Config must be a YAML object"));
}
const obj = parsed as Record<string, unknown>;
const sensesResult = parseSenses(obj);
if (!sensesResult.ok) return sensesResult;
const { senses, senseNames } = sensesResult.value;
const reflexesResult = parseReflexes(obj, senseNames);
if (!reflexesResult.ok) return reflexesResult;
const workflowsResult = parseWorkflows(obj);
if (!workflowsResult.ok) return workflowsResult;
// Cross-validate: workflow reflexes must reference defined workflows
const workflowNames = new Set(workflowsResult.value ? Object.keys(workflowsResult.value) : []);
for (let i = 0; i < reflexesResult.value.length; i++) {
const reflex = reflexesResult.value[i];
if (reflex.kind === "workflow" && !workflowNames.has(reflex.workflow)) {
return err(new Error(`reflexes[${i}].workflow: "${reflex.workflow}" not found in workflows`));
}
}
return ok({
senses,
reflexes: reflexesResult.value,
workflows: workflowsResult.value,
});
}
export type NerveConfig = {
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
maxRounds: number;
senses: Record<string, SenseConfig>;
reflexes: ReflexConfig[];
workflows: Record<string, WorkflowConfig>;
};
+105
View File
@@ -0,0 +1,105 @@
/**
* Daemon Unix-socket IPC protocol (CLI → daemon).
* Newline-delimited JSON: one request object per line from the client,
* one response object per line from the daemon.
*/
import { isPlainRecord } from "./is-plain-record.js";
import type { SenseInfo } from "./sense.js";
/** Client → daemon: start a workflow run. */
export type DaemonIpcTriggerWorkflowRequest = {
type: "trigger-workflow";
workflow: string;
prompt: string;
maxRounds: number;
dryRun: boolean;
};
/** Client → daemon: run a sense compute on demand. */
export type DaemonIpcTriggerSenseRequest = {
type: "trigger-sense";
sense: string;
};
/** Client → daemon: list registered senses. */
export type DaemonIpcListSensesRequest = {
type: "list-senses";
};
/** Client → daemon: kill a running or queued workflow thread by runId. */
export type DaemonIpcKillWorkflowRequest = {
type: "kill-workflow";
runId: string;
};
/** Union of all JSON requests the daemon IPC server accepts. */
export type DaemonIpcRequest =
| DaemonIpcTriggerWorkflowRequest
| DaemonIpcTriggerSenseRequest
| DaemonIpcListSensesRequest
| DaemonIpcKillWorkflowRequest;
/** Successful trigger / trigger-sense reply (no body). */
export type DaemonIpcTriggerOkResponse = { ok: true };
export type DaemonIpcErrorResponse = { ok: false; error: string };
/** Replies for trigger-workflow and trigger-sense. */
export type DaemonIpcTriggerResponse = DaemonIpcTriggerOkResponse | DaemonIpcErrorResponse;
/** Reply for list-senses. */
export type DaemonIpcListSensesResponse =
| { ok: true; senses: SenseInfo[] }
| DaemonIpcErrorResponse;
/** Any JSON response the daemon may write on the IPC socket. */
export type DaemonIpcResponse =
| DaemonIpcTriggerOkResponse
| DaemonIpcErrorResponse
| { ok: true; senses: SenseInfo[] };
function parseTriggerWorkflowFields(
req: Record<string, unknown>,
): DaemonIpcTriggerWorkflowRequest | null {
if (typeof req.workflow !== "string" || req.workflow.length === 0) return null;
if (typeof req.prompt !== "string") return null;
if (typeof req.maxRounds !== "number") return null;
const dryRun = typeof req.dryRun === "boolean" ? req.dryRun : false;
return {
type: "trigger-workflow",
workflow: req.workflow,
prompt: req.prompt,
maxRounds: req.maxRounds,
dryRun,
};
}
/**
* Parse a single line of JSON into a {@link DaemonIpcRequest}, or null if invalid.
* Kept in core with the request types so CLI and daemon stay aligned at compile time.
*/
export function parseDaemonIpcRequest(line: string): DaemonIpcRequest | null {
try {
const obj: unknown = JSON.parse(line);
if (!isPlainRecord(obj)) return null;
const req = obj;
if (req.type === "trigger-workflow") {
return parseTriggerWorkflowFields(req);
}
if (req.type === "trigger-sense") {
if (typeof req.sense !== "string" || req.sense.length === 0) return null;
return { type: "trigger-sense", sense: req.sense };
}
if (req.type === "list-senses") {
return { type: "list-senses" };
}
if (req.type === "kill-workflow") {
if (typeof req.runId !== "string" || req.runId.length === 0) return null;
return { type: "kill-workflow", runId: req.runId };
}
return null;
} catch {
return null;
}
}
+39 -4
View File
@@ -1,14 +1,49 @@
export type {
Signal,
SenseConfig,
SenseReflexConfig,
WorkflowReflexConfig,
ReflexConfig,
DropOverflowConfig,
QueueOverflowConfig,
WorkflowConfig,
NerveConfig,
} from "./types.js";
} from "./config.js";
export type { Signal, SenseInfo, SenseResult } from "./sense.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type { Result } from "./result.js";
export { ok, err } from "./result.js";
export { parseNerveConfig } from "./config.js";
export { parseNerveConfig } from "./parse-nerve-config.js";
export { isPlainRecord } from "./is-plain-record.js";
export type {
ParsedSenseWorkflowDirective,
SenseComputeRoute,
} from "./sense-workflow-directive.js";
export {
parseSenseWorkflowDirective,
routeSenseComputeOutput,
} from "./sense-workflow-directive.js";
export type {
DaemonIpcTriggerWorkflowRequest,
DaemonIpcTriggerSenseRequest,
DaemonIpcListSensesRequest,
DaemonIpcKillWorkflowRequest,
DaemonIpcRequest,
DaemonIpcTriggerOkResponse,
DaemonIpcErrorResponse,
DaemonIpcTriggerResponse,
DaemonIpcListSensesResponse,
DaemonIpcResponse,
} from "./daemon-ipc-protocol.js";
export { parseDaemonIpcRequest } from "./daemon-ipc-protocol.js";
+7
View File
@@ -0,0 +1,7 @@
/**
* Narrows `unknown` to a plain JSON-style object (not null, not array).
* Use after `JSON.parse` / YAML / IPC when validating structure field-by-field.
*/
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+302
View File
@@ -0,0 +1,302 @@
import { parse } from "yaml";
import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./config.js";
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
const DURATION_RE = /^(\d+)([smh])$/;
const DURATION_MULTIPLIERS: Record<string, number> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
};
function parseDurationToMs(value: string): number | null {
const match = DURATION_RE.exec(value);
if (!match) return null;
return Number(match[1]) * DURATION_MULTIPLIERS[match[2]];
}
function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value);
}
function parseDurationField(field: unknown, label: string): Result<number | null> {
if (field === undefined || field === null) return ok(null);
if (typeof field !== "string") {
return err(
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
);
}
const ms = parseDurationToMs(field);
if (ms === null) {
return err(
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
);
}
return ok(ms);
}
function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`senses.${name}: must be an object`));
}
const obj = raw;
if (typeof obj.group !== "string" || obj.group.trim() === "") {
return err(new Error(`senses.${name}.group: required string`));
}
if (!isValidGroupName(obj.group)) {
return err(
new Error(
`senses.${name}.group: invalid name "${obj.group}" (only alphanumeric, underscore, hyphen allowed)`,
),
);
}
const throttleResult = parseDurationField(obj.throttle, `senses.${name}.throttle`);
if (!throttleResult.ok) return throttleResult;
const timeoutResult = parseDurationField(obj.timeout, `senses.${name}.timeout`);
if (!timeoutResult.ok) return timeoutResult;
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
if (!graceResult.ok) return graceResult;
return ok({
group: obj.group,
throttle: throttleResult.value,
timeout: timeoutResult.value,
gracePeriod: graceResult.value,
});
}
function parseOnField(index: number, obj: Record<string, unknown>): Result<string[]> {
if (obj.on === undefined || obj.on === null) return ok([]);
if (!Array.isArray(obj.on) || !obj.on.every((item): item is string => typeof item === "string")) {
return err(new Error(`reflexes[${index}].on: must be an array of strings`));
}
return ok(obj.on);
}
function parseSenseReflex(
index: number,
obj: Record<string, unknown>,
senseNames: Set<string>,
on: string[],
): Result<ReflexConfig> {
if (typeof obj.sense !== "string") {
return err(new Error(`reflexes[${index}].sense: must be a string`));
}
if (!senseNames.has(obj.sense)) {
return err(new Error(`reflexes[${index}].sense: "${obj.sense}" not found in senses`));
}
const intervalResult = parseDurationField(obj.interval, `reflexes[${index}].interval`);
if (!intervalResult.ok) return intervalResult;
if (intervalResult.value === null && on.length === 0) {
return err(
new Error(`reflexes[${index}]: sense reflex must have at least one of "interval" or "on"`),
);
}
return ok({
kind: "sense" as const,
sense: obj.sense,
interval: intervalResult.value,
on,
});
}
function validateReflexConfig(
index: number,
raw: unknown,
senseNames: Set<string>,
): Result<ReflexConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`reflexes[${index}]: must be an object`));
}
const obj = raw;
const hasSense = obj.sense !== undefined;
const hasWorkflowKey = Object.hasOwn(obj, "workflow");
if (hasWorkflowKey) {
return err(
new Error(
`reflexes[${index}]: YAML "workflow" entries are not supported — start workflows from a Sense compute return value using a "workflow" string field (format: name|maxRounds|prompt)`,
),
);
}
if (!hasSense) {
return err(new Error(`reflexes[${index}]: must include "sense"`));
}
const onResult = parseOnField(index, obj);
if (!onResult.ok) return onResult;
return parseSenseReflex(index, obj, senseNames, onResult.value);
}
function parseEngineMaxRounds(obj: Record<string, unknown>): Result<number> {
if (obj.max_rounds === undefined || obj.max_rounds === null) {
return ok(DEFAULT_ENGINE_MAX_ROUNDS);
}
if (
typeof obj.max_rounds !== "number" ||
!Number.isInteger(obj.max_rounds) ||
obj.max_rounds < 1
) {
return err(new Error("max_rounds: must be a positive integer"));
}
return ok(obj.max_rounds);
}
function validateWorkflowConfig(name: string, raw: unknown): Result<WorkflowConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`workflows.${name}: must be an object`));
}
const obj = raw;
if (
typeof obj.concurrency !== "number" ||
!Number.isInteger(obj.concurrency) ||
obj.concurrency < 1
) {
return err(new Error(`workflows.${name}.concurrency: must be a positive integer`));
}
if (obj.overflow !== "drop" && obj.overflow !== "queue") {
return err(new Error(`workflows.${name}.overflow: must be "drop" or "queue"`));
}
if (obj.overflow === "drop") {
if (obj.max_queue !== undefined && obj.max_queue !== null) {
return err(new Error(`workflows.${name}: max_queue is not allowed with overflow "drop"`));
}
return ok({
concurrency: obj.concurrency,
overflow: "drop" as const,
});
}
// overflow: "queue"
let maxQueue = 100; // default
if (obj.max_queue !== undefined && obj.max_queue !== null) {
if (
typeof obj.max_queue !== "number" ||
!Number.isInteger(obj.max_queue) ||
obj.max_queue < 1
) {
return err(new Error(`workflows.${name}.max_queue: must be a positive integer`));
}
maxQueue = obj.max_queue;
}
return ok({
concurrency: obj.concurrency,
overflow: "queue" as const,
maxQueue,
});
}
function parseSenses(
obj: Record<string, unknown>,
): Result<{ senses: Record<string, SenseConfig>; senseNames: Set<string> }> {
if (!isPlainRecord(obj.senses)) {
return err(new Error("senses: required object"));
}
const sensesRaw = obj.senses;
const senses: Record<string, SenseConfig> = {};
const senseNames = new Set(Object.keys(sensesRaw));
for (const [name, senseRaw] of Object.entries(sensesRaw)) {
const result = validateSenseConfig(name, senseRaw);
if (!result.ok) return result;
senses[name] = result.value;
}
return ok({ senses, senseNames });
}
function parseReflexes(
obj: Record<string, unknown>,
senseNames: Set<string>,
): Result<ReflexConfig[]> {
if (!Array.isArray(obj.reflexes)) {
return err(new Error("reflexes: required array"));
}
const reflexes: ReflexConfig[] = [];
for (let i = 0; i < obj.reflexes.length; i++) {
const result = validateReflexConfig(i, obj.reflexes[i], senseNames);
if (!result.ok) return result;
reflexes.push(result.value);
}
return ok(reflexes);
}
function parseWorkflows(obj: Record<string, unknown>): Result<Record<string, WorkflowConfig>> {
if (obj.workflows === undefined || obj.workflows === null) return ok({});
if (!isPlainRecord(obj.workflows)) {
return err(new Error("workflows: must be an object if provided"));
}
const workflowsRaw = obj.workflows;
const workflows: Record<string, WorkflowConfig> = {};
for (const [name, wfRaw] of Object.entries(workflowsRaw)) {
const result = validateWorkflowConfig(name, wfRaw);
if (!result.ok) return result;
workflows[name] = result.value;
}
return ok(workflows);
}
export function parseNerveConfig(raw: string): Result<NerveConfig> {
let parsed: unknown;
try {
parsed = parse(raw);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(new Error(`YAML parse error: ${message}`));
}
if (!isPlainRecord(parsed)) {
return err(new Error("Config must be a YAML object"));
}
const obj = parsed;
const sensesResult = parseSenses(obj);
if (!sensesResult.ok) return sensesResult;
const { senses, senseNames } = sensesResult.value;
const reflexesResult = parseReflexes(obj, senseNames);
if (!reflexesResult.ok) return reflexesResult;
const workflowsResult = parseWorkflows(obj);
if (!workflowsResult.ok) return workflowsResult;
const maxRoundsResult = parseEngineMaxRounds(obj);
if (!maxRoundsResult.ok) return maxRoundsResult;
return ok({
maxRounds: maxRoundsResult.value,
senses,
reflexes: reflexesResult.value,
workflows: workflowsResult.value,
});
}
@@ -0,0 +1,77 @@
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
/** Parsed `workflow-name|maxRounds|prompt` from a Sense compute return value. */
export type ParsedSenseWorkflowDirective = {
workflowName: string;
maxRounds: number;
prompt: string;
};
/**
* Parses the pipe-separated `workflow` field from a Sense compute result.
* `prompt` may contain `|` — only the first two pipes delimit name and rounds.
*/
export function parseSenseWorkflowDirective(field: string): Result<ParsedSenseWorkflowDirective> {
const trimmed = field.trim();
if (trimmed.length === 0) {
return err(new Error("workflow directive is empty"));
}
const parts = trimmed.split("|");
if (parts.length < 3) {
return err(
new Error(
`workflow directive must be "name|maxRounds|prompt" (got ${String(parts.length)} segment(s))`,
),
);
}
const workflowName = (parts[0] ?? "").trim();
if (workflowName.length === 0) {
return err(new Error("workflow directive: empty workflow name"));
}
const roundsRaw = (parts[1] ?? "").trim();
const maxRounds = Number.parseInt(roundsRaw, 10);
if (!Number.isInteger(maxRounds) || maxRounds < 1) {
return err(new Error(`workflow directive: invalid maxRounds "${roundsRaw}"`));
}
const prompt = parts.slice(2).join("|");
return ok({ workflowName, maxRounds, prompt });
}
export type SenseComputeRoute =
| { kind: "launch"; launch: ParsedSenseWorkflowDirective }
| { kind: "signal"; payload: unknown };
function stripWorkflowKey(payload: Record<string, unknown>): Record<string, unknown> {
const { workflow: _drop, ...rest } = payload;
return rest;
}
/**
* Interprets a Sense compute non-null return value for the engine:
* - `workflow` missing → normal signal with full payload
* - `workflow: null` or `""` → normal signal; `workflow` key stripped from emitted payload
* - `workflow: "name|n|prompt"` → launch workflow; no Signal is emitted to the bus
*/
export function routeSenseComputeOutput(payload: unknown): SenseComputeRoute {
if (!isPlainRecord(payload)) {
return { kind: "signal", payload };
}
const obj = payload;
if (!Object.hasOwn(obj, "workflow")) {
return { kind: "signal", payload };
}
const w = obj.workflow;
if (w === null || w === "") {
return { kind: "signal", payload: stripWorkflowKey(obj) };
}
if (typeof w !== "string") {
return { kind: "signal", payload };
}
const parsed = parseSenseWorkflowDirective(w);
if (!parsed.ok) {
return { kind: "signal", payload: stripWorkflowKey(obj) };
}
return { kind: "launch", launch: parsed.value };
}
+21
View File
@@ -0,0 +1,21 @@
export type Signal = {
id: number;
senseId: string;
payload: unknown;
timestamp: number;
};
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
export type SenseInfo = {
name: string;
group: string;
throttle: number | null;
timeout: number | null;
lastSignalTimestamp: number | null;
};
/** The result of a Sense compute — payload plus optional workflow directive. */
export type SenseResult<T = unknown> = {
payload: T;
workflow: string | null;
};
-47
View File
@@ -1,47 +0,0 @@
export type Signal = {
id: number;
senseId: string;
payload: unknown;
ts: number;
};
export type SenseConfig = {
group: string;
throttle: number | null;
timeout: number | null;
gracePeriod: number | null;
};
export type SenseReflexConfig = {
kind: "sense";
sense: string;
interval: number | null;
on: string[] | null;
};
export type WorkflowReflexConfig = {
kind: "workflow";
workflow: string;
on: string[] | null;
};
export type ReflexConfig = SenseReflexConfig | WorkflowReflexConfig;
export type DropOverflowConfig = {
concurrency: number;
overflow: "drop";
};
export type QueueOverflowConfig = {
concurrency: number;
overflow: "queue";
maxQueue: number;
};
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
export type NerveConfig = {
senses: Record<string, SenseConfig>;
reflexes: ReflexConfig[];
workflows: Record<string, WorkflowConfig> | null;
};
+75
View File
@@ -0,0 +1,75 @@
// ---------------------------------------------------------------------------
// Workflow Automaton types (issue #80)
// ---------------------------------------------------------------------------
export const START = "__start__" as const;
export const END = "__end__" as const;
export type START = typeof START;
export type END = typeof END;
/** Engine-wide fallback for max moderator rounds when not specified in config. */
export const DEFAULT_ENGINE_MAX_ROUNDS = 100;
/** A single message in the workflow conversation chain (runtime, type-erased). */
export type WorkflowMessage = {
role: string;
content: string;
meta: unknown;
timestamp: number;
};
/** The typed output of a Role execution. */
export type RoleResult<Meta> = { content: string; meta: Meta };
/**
* A Role is a pure async function: receives the engine start frame plus prior
* role messages only (the start frame is not included in `messages`).
* Returns typed content + meta. Implementation can be an agent, LLM call,
* script, HTTP request, etc.
*/
export type Role<Meta> = (
start: StartStep,
messages: WorkflowMessage[],
) => Promise<RoleResult<Meta>>;
/** Maps role names to their meta types — the single generic that drives all inference. */
export type RoleMeta = Record<string, Record<string, unknown>>;
/** Engine start frame: prompt, max rounds cap, dry-run flag, and timestamps for the thread. */
export type StartStep = {
role: START;
content: string;
meta: { maxRounds: number; dryRun: boolean };
timestamp: number;
};
/** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */
export type RoleStep<M extends RoleMeta> = {
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
}[keyof M & string];
/**
* Moderator input: the complete workflow history.
* Contains the start frame and all role steps so far.
* On initial call, `steps` is empty — moderator can check `steps.length === 0`.
* Round count is `steps.length`; maxRounds is in `start.meta.maxRounds`.
*/
export type ModeratorContext<M extends RoleMeta> = {
start: StartStep;
steps: RoleStep<M>[];
};
/**
* The moderator — a pure routing function. Receives the full workflow context
* (start frame + all prior steps). Returns the next role name or END.
*/
export type Moderator<M extends RoleMeta> = (
context: ModeratorContext<M>,
) => (keyof M & string) | END;
/** The complete definition of a workflow, as authored by users. */
export type WorkflowDefinition<M extends RoleMeta> = {
name: string;
roles: { [K in keyof M & string]: Role<M[K]> };
moderator: Moderator<M>;
};
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
});
+92
View File
@@ -0,0 +1,92 @@
# @uncaged/nerve-daemon
The observation engine runtime for [nerve](../../README.md) — runs senses, routes signals, schedules reflexes, and manages workflows.
## Architecture
| Module | Source (indicative) | Responsibility |
|--------|---------------------|----------------|
| **Kernel** | `kernel.ts` | Orchestrator — worker pool, signal bus, reflex scheduler, workflow manager, optional file watcher and daemon IPC, config reload hooks |
| **Worker pool** | `worker-pool.ts` | Fork and supervise one child process per sense group; restart/shutdown; crash cleanup hooks for scheduler state |
| **Kernel sense groups** | `kernel-sense-groups.ts` | Derive sense groups from config; list senses per group for scheduling |
| **Sense runtime** | sense worker + Drizzle | Per-sense SQLite (`node:sqlite`), migrations, peer DB reads |
| **Sense worker** | `sense-worker.ts` (fork target) | Child process entry — runs `compute()` per sense in a group |
| **Signal bus** | `signal-bus.ts` | In-memory pub/sub for sense signals |
| **Reflex scheduler** | `reflex-scheduler.ts` | Interval + `on` subscriptions, throttle/coalesce |
| **Workflow manager** | `workflow-manager.ts` | One worker per workflow name, concurrency (drop/queue), queue caps |
| **Workflow worker** | `workflow-worker.ts` | Child process — runs RFC-002 threads (`start-thread`, `resume-thread` IPC) |
| **IPC (parent ↔ workers)** | `ipc.ts` | Typed messages for sense and workflow workers (includes `resume-thread` for recovery) |
| **Log / workflow persistence** | via `@uncaged/nerve-store` | Structured logs, `workflow_runs`, thread messages (used for recovery) |
| **Blob store** | `@uncaged/nerve-store` | CAS under `data/blobs/` — sense workers construct `createBlobStore(join(nerveRoot, "data", "blobs"))` for artifact writes |
| **File watcher** | `file-watcher.ts` | Watches workspace paths for config / sense / workflow file changes |
| **Kernel file watch** | `kernel-file-watch.ts` | Maps watcher events to `reloadConfig`, sense group restart, workflow `drainAndRespawn` |
| **Daemon IPC** | `daemon-ipc.ts` | Unix socket server — parses `@uncaged/nerve-core` `DaemonIpcRequest`, dispatches trigger-workflow / trigger-sense / list-senses |
## Crash recovery (workflow workers)
If a workflow worker exits unexpectedly while threads are active:
- In-flight runs are marked **`crashed`** in the log store; the manager respawns a fresh worker.
- Runs still in **`started`** state can be **`resume-thread`**’d: the manager rebuilds the message chain from persisted workflow log rows and sends `resume-thread` to the new worker.
- **Crash-loop backoff:** repeated crashes for the same workflow name are counted in a sliding window (`60s`); after **`5`** crashes in that window, the manager **stops respawning** that worker and logs the condition (avoids tight crash loops).
Hot reload (`drainAndRespawn`) uses a controlled drain: in-flight runs may be marked **`interrupted`** when the old worker is torn down after a timeout — that path is distinct from unexpected crash recovery.
## Key Design Decisions
- **One worker process per sense group** — isolation between groups, shared compute within a group
- **`node:sqlite` (DatabaseSync)** — zero native addons, WAL mode, built into Node.js ≥ 22.5
- **Throttle + coalesce** — if compute is in-flight, at most one pending trigger is queued (no unbounded accumulation)
- **Log ≠ Signal** — logs are queryable data assets but cannot trigger reflexes (prevents feedback loops)
## Usage
The daemon is typically started via the CLI (`nerve daemon start` / `nerve dev`), but you can embed the kernel:
```typescript
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { createKernel } from "@uncaged/nerve-daemon";
const nerveRoot = "/path/to/workspace";
const yamlPath = join(nerveRoot, "nerve.yaml");
const parsed = parseNerveConfig(readFileSync(yamlPath, "utf8"));
if (!parsed.ok) {
throw parsed.error;
}
const kernel = createKernel(parsed.value, nerveRoot, {
enableFileWatcher: true,
ipcSocketPath: join(nerveRoot, "nerve.sock"),
});
await kernel.ready;
kernel.triggerSense("cpu-usage");
const health = kernel.getHealth();
await kernel.stop();
```
`createKernel(config, nerveRoot, options?)``config` is a parsed `NerveConfig`; `nerveRoot` is the workspace root (contains `nerve.yaml`, `data/`, etc.). Optional `KernelOptions`:
| Field | Meaning |
|-------|---------|
| `workerScript` | Override path to the sense worker entry script (defaults to the package’s resolved worker) |
| `enableFileWatcher` | Watch config / senses / workflows for hot reload |
| `logStore` | Inject a `LogStore` instance (defaults to `createLogStore(join(nerveRoot, "data", "logs.db"))`) |
| `ipcSocketPath` | When non-null, listen for daemon IPC on this Unix socket path |
## Install
```bash
pnpm add @uncaged/nerve-daemon
```
Requires Node.js ≥ 22.5 (for `node:sqlite`).
## License
MIT
+11 -6
View File
@@ -1,22 +1,27 @@
{
"name": "@uncaged/nerve-daemon",
"version": "0.0.1",
"private": true,
"version": "0.4.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsup",
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.43.1",
"@uncaged/nerve-store": "workspace:*",
"drizzle-orm": "1.0.0-beta.23-c10d10c",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
"sense-worker": "src/sense-worker.ts",
"workflow-worker": "src/workflow-worker.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
@@ -0,0 +1,448 @@
/**
* Phase 3 — Worker crash recovery tests.
*
* Verifies that WorkflowManager correctly:
* - Marks in-flight threads as "crashed" in the DB when a worker exits unexpectedly
* - Respawns the worker after a crash
* - Resumes "started" threads from persisted event history (resume-thread IPC)
* - Re-queues "queued" threads so they are dispatched on the new worker
*/
import { EventEmitter } from "node:events";
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockChild = EventEmitter & {
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
connected: boolean;
exitCode: number | null;
pid: number;
};
const mockChildren: MockChild[] = [];
function makeMockChild(pid = 1): MockChild {
const child = new EventEmitter() as MockChild;
child.connected = true;
child.exitCode = null;
child.pid = pid;
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "shutdown"
) {
setImmediate(() => {
child.exitCode = 0;
child.connected = false;
child.emit("exit", 0, null);
});
}
});
child.kill = vi.fn((_signal?: string) => {
child.exitCode = 1;
child.connected = false;
child.emit("exit", null, _signal ?? "SIGKILL");
});
return child;
}
vi.mock("node:child_process", () => ({
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
const child = makeMockChild(mockChildren.length + 1);
mockChildren.push(child);
return child;
}),
}));
const { createWorkflowManager } = await import("../workflow-manager.js");
function makeConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig {
return {
senses: {},
reflexes: [],
workflows,
maxRounds: 10,
};
}
function makeLogStore(
activeRuns: Array<{
runId: string;
workflow: string;
status: "queued" | "started";
timestamp: number;
}> = [],
) {
const runsWithExitCode = activeRuns.map((r) => ({ ...r, exitCode: null }));
const store = {
append: vi.fn(),
query: vi.fn(() => []),
getMeta: vi.fn(() => null),
setMeta: vi.fn(),
upsertWorkflowRun: vi.fn(),
appendWithWorkflowUpdate: vi.fn(),
getWorkflowRun: vi.fn(() => null),
getActiveWorkflowRuns: vi.fn((_workflowName?: string) => {
if (_workflowName !== undefined) {
return runsWithExitCode.filter((r) => r.workflow === _workflowName);
}
return runsWithExitCode;
}),
getTriggerPayload: vi.fn((): unknown => ({ value: 42 })),
getThreadEvents: vi.fn(
(): Array<{ type: string; [key: string]: unknown }> => [
{ type: "thread_start", triggerPayload: {} },
],
),
getThreadMessages: vi.fn(
(): Array<{ role: string; content: string; meta: unknown; timestamp: number }> => [],
),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
getAllWorkflowRuns: vi.fn(() => []),
};
return store;
}
describe("WorkflowManager — crash recovery (Phase 3)", () => {
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe("worker crash marks active threads as crashed", () => {
it("logs 'crashed' status for each active thread when worker exits unexpectedly", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-wf": { concurrency: 2, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
// Simulate unexpected exit (not shutdown)
const child = mockChildren[0];
child.exitCode = 1;
child.connected = false;
child.emit("exit", 1, null);
const crashedCalls = logStore.upsertWorkflowRun.mock.calls.filter(
(args: any[]) => (args[0] as { type: string }).type === "crashed",
);
expect(crashedCalls).toHaveLength(2);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("clears active count after crash", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-wf": { concurrency: 3, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
const child = mockChildren[0];
child.exitCode = 1;
child.connected = false;
child.emit("exit", 1, null);
expect(mgr.activeCount("my-wf")).toBe(0);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("worker crash triggers respawn", () => {
it("spawns a new worker after crash", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-wf": { concurrency: 1, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
const child = mockChildren[0];
child.exitCode = 1;
child.connected = false;
child.emit("exit", 1, null);
// setImmediate to allow respawn
await vi.runAllTimersAsync();
expect(mockChildren).toHaveLength(2);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("sends resume-thread for 'started' runs from DB after respawn", async () => {
const activeRuns = [
{ runId: "run-started-1", workflow: "my-wf", status: "started" as const, timestamp: 1000 },
];
const logStore = makeLogStore(activeRuns);
logStore.getThreadMessages.mockReturnValue([
{ role: "scanner", content: "done", meta: { items: ["a"] }, timestamp: 1000 },
]);
logStore.getTriggerPayload.mockReturnValue({ trigger: "initial" });
const config = makeConfig({
"my-wf": { concurrency: 2, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
firstChild.emit("exit", 1, null);
await vi.runAllTimersAsync();
// New worker should have been spawned
const secondChild = mockChildren[1];
expect(secondChild).toBeDefined();
// resume-thread should have been sent
const resumeCalls = (secondChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
(args: any[]) =>
args[0] !== null &&
typeof args[0] === "object" &&
(args[0] as Record<string, unknown>).type === "resume-thread",
);
expect(resumeCalls).toHaveLength(1);
expect(resumeCalls[0][0]).toMatchObject({
type: "resume-thread",
runId: "run-started-1",
});
expect(Array.isArray((resumeCalls[0][0] as Record<string, unknown>).messages)).toBe(true);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("re-queues 'queued' runs from DB after respawn", async () => {
const activeRuns = [
{ runId: "run-queued-1", workflow: "my-wf", status: "queued" as const, timestamp: 900 },
];
const logStore = makeLogStore(activeRuns);
logStore.getTriggerPayload.mockReturnValue({ queued: "payload" });
const config = makeConfig({
"my-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// Start one thread to fill the concurrency slot (so queued run stays queued on respawn)
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
firstChild.emit("exit", 1, null);
await vi.runAllTimersAsync();
// After respawn, the queue should contain the recovered run
expect(mgr.queueLength("my-wf")).toBeGreaterThanOrEqual(1);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("workflow messages are persisted (for crash recovery replay)", () => {
it("persists thread_workflow_message when worker sends thread-workflow-message IPC", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-wf": { concurrency: 1, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const child = mockChildren[0];
const startCall = (child.send as ReturnType<typeof vi.fn>).mock.calls[0];
const runId = (startCall[0] as Record<string, unknown>).runId as string;
child.emit("message", {
type: "thread-workflow-message",
runId,
message: { role: "scanner", content: "done", meta: { items: ["a", "b"] }, timestamp: 1000 },
});
const appendCalls = logStore.append.mock.calls.filter(
(args: any[]) => (args[0] as { type: string }).type === "thread_workflow_message",
);
expect(appendCalls).toHaveLength(1);
expect(appendCalls[0][0]).toMatchObject({
source: "workflow",
type: "thread_workflow_message",
refId: runId,
});
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("triggerPayload is persisted in 'started' log entry", () => {
it("stores triggerPayload in the payload field of the started log entry", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-wf": { concurrency: 1, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
const launch = { prompt: "build-docker for myrepo", maxRounds: 10, dryRun: false };
mgr.startWorkflow("my-wf", launch);
const startedCall = logStore.upsertWorkflowRun.mock.calls.find(
(args: any[]) => (args[0] as { type: string }).type === "started",
);
expect(startedCall).toBeDefined();
const logEntry = startedCall?.[0] as { payload: string | null };
expect(logEntry.payload).not.toBeNull();
const parsed = JSON.parse(logEntry.payload as string) as Record<string, unknown>;
expect(parsed).toMatchObject({
prompt: "build-docker for myrepo",
maxRounds: 10,
dryRun: false,
});
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("runId deduplication in crash recovery", () => {
it("does not push duplicate runIds into the queue during crash recovery", async () => {
const activeRuns = [
{ runId: "run-queued-dup", workflow: "my-wf", status: "queued" as const, timestamp: 900 },
];
const logStore = makeLogStore(activeRuns);
logStore.getTriggerPayload.mockReturnValue({ q: 1 });
const config = makeConfig({
"my-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// Start one thread to fill the concurrency slot
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const firstChild = mockChildren[0];
// Crash once → respawn → crash again → second respawn
firstChild.exitCode = 1;
firstChild.connected = false;
firstChild.emit("exit", 1, null);
await vi.runAllTimersAsync();
const secondChild = mockChildren[1];
secondChild.exitCode = 1;
secondChild.connected = false;
secondChild.emit("exit", 1, null);
await vi.runAllTimersAsync();
// The recovered queued run should appear at most once in the queue
expect(mgr.queueLength("my-wf")).toBeLessThanOrEqual(1);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("does not add duplicate active runIds during crash recovery", async () => {
const activeRuns = [
{
runId: "run-started-dup",
workflow: "my-wf",
status: "started" as const,
timestamp: 1000,
},
];
const logStore = makeLogStore(activeRuns);
logStore.getThreadMessages.mockReturnValue([]);
logStore.getTriggerPayload.mockReturnValue({ s: 1 });
const config = makeConfig({
"my-wf": { concurrency: 2, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
firstChild.emit("exit", 1, null);
await vi.runAllTimersAsync();
const secondChild = mockChildren[1];
secondChild.exitCode = 1;
secondChild.connected = false;
secondChild.emit("exit", 1, null);
await vi.runAllTimersAsync();
// The active set should not double-count the recovered run
expect(mgr.activeCount("my-wf")).toBeLessThanOrEqual(1);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("crash-loop backoff", () => {
it("stops respawning after exceeding max crashes in the window", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"crash-wf": { concurrency: 1, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("crash-wf", { prompt: "test", maxRounds: 10, dryRun: false });
// Crash the worker 6 times in rapid succession (within CRASH_WINDOW_MS = 60s)
for (let i = 0; i < 6; i++) {
const child = mockChildren[mockChildren.length - 1];
child.exitCode = 1;
child.connected = false;
child.emit("exit", 1, null);
await vi.runAllTimersAsync();
}
// After 6 crashes, no new worker should be spawned
// The 1st crash spawns child[1], ..., 5th crash spawns child[5], 6th should NOT spawn
expect(mockChildren.length).toBeLessThanOrEqual(6);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
});
@@ -0,0 +1,255 @@
/**
* Unit + integration tests for daemon-ipc.ts — trigger-sense request type.
*
* Tests cover:
* - parseDaemonIpcRequest (core) / server correctly accept or reject trigger-sense messages
* - createDaemonIpcServer routes trigger-sense to opts.triggerSense
* - Error response when triggerSense throws (unknown sense)
* - Success response on valid sense trigger
*/
import { rmSync } from "node:fs";
import { connect } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDaemonIpcServer } from "../daemon-ipc.js";
import type { DaemonIpcServer } from "../daemon-ipc.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
let sockPath: string;
let server: DaemonIpcServer | null = null;
function makeMockWorkflowManager() {
return {
startWorkflow: vi.fn(),
stop: vi.fn(async () => {}),
totalActiveCount: vi.fn(() => 0),
drainAndRespawn: vi.fn(async () => {}),
updateConfig: vi.fn(),
getActiveWorkflowRuns: vi.fn(() => []),
};
}
function sendRaw(path: string, message: object): Promise<object> {
return new Promise((resolve, reject) => {
const sock = connect(path, () => {
let buf = "";
sock.on("data", (chunk: Buffer) => {
buf += chunk.toString("utf8");
const lines = buf.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
try {
resolve(JSON.parse(trimmed) as object);
} catch {
reject(new Error(`Invalid JSON response: ${trimmed}`));
}
sock.destroy();
return;
}
buf = lines[lines.length - 1] ?? "";
});
sock.write(`${JSON.stringify(message)}\n`);
});
sock.on("error", reject);
});
}
beforeEach(() => {
sockPath = join(
tmpdir(),
`nerve-ipc-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`,
);
});
afterEach(async () => {
if (server !== null) {
await server.close();
server = null;
}
try {
rmSync(sockPath);
} catch {
// already removed
}
});
// ---------------------------------------------------------------------------
// trigger-sense: valid request → ok: true
// ---------------------------------------------------------------------------
describe("daemon-ipc — trigger-sense", () => {
it("responds ok:true when triggerSense succeeds", async () => {
const triggerSense = vi.fn();
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense,
listSenses: vi.fn(() => []),
});
const resp = await sendRaw(sockPath, { type: "trigger-sense", sense: "cpu-usage" });
expect(resp).toEqual({ ok: true });
expect(triggerSense).toHaveBeenCalledOnce();
expect(triggerSense).toHaveBeenCalledWith("cpu-usage");
});
it("responds ok:false with error message when triggerSense throws", async () => {
const triggerSense = vi.fn(() => {
throw new Error('Unknown sense: "no-such-sense"');
});
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense,
listSenses: vi.fn(() => []),
});
const resp = await sendRaw(sockPath, { type: "trigger-sense", sense: "no-such-sense" });
expect(resp).toEqual({ ok: false, error: 'Unknown sense: "no-such-sense"' });
expect(triggerSense).toHaveBeenCalledWith("no-such-sense");
});
it("responds ok:false for trigger-sense with empty sense name", async () => {
const triggerSense = vi.fn();
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense,
listSenses: vi.fn(() => []),
});
const resp = await sendRaw(sockPath, { type: "trigger-sense", sense: "" });
expect(resp).toEqual({ ok: false, error: "Invalid request" });
expect(triggerSense).not.toHaveBeenCalled();
});
it("responds ok:false for trigger-sense missing sense field", async () => {
const triggerSense = vi.fn();
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense,
listSenses: vi.fn(() => []),
});
const resp = await sendRaw(sockPath, { type: "trigger-sense" });
expect(resp).toEqual({ ok: false, error: "Invalid request" });
expect(triggerSense).not.toHaveBeenCalled();
});
it("does NOT call triggerSense for trigger-workflow requests", async () => {
const triggerSense = vi.fn();
const wfManager = makeMockWorkflowManager();
server = createDaemonIpcServer(sockPath, wfManager as never, {
triggerSense,
listSenses: vi.fn(() => []),
});
const resp = await sendRaw(sockPath, {
type: "trigger-workflow",
workflow: "my-workflow",
prompt: "test prompt",
maxRounds: 10,
dryRun: false,
});
expect(resp).toEqual({ ok: true });
expect(triggerSense).not.toHaveBeenCalled();
expect(wfManager.startWorkflow).toHaveBeenCalledWith("my-workflow", {
prompt: "test prompt",
maxRounds: 10,
dryRun: false,
});
});
it("responds ok:false for completely unknown request type", async () => {
const triggerSense = vi.fn();
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense,
listSenses: vi.fn(() => []),
});
const resp = await sendRaw(sockPath, { type: "unknown-type", data: "x" });
expect(resp).toEqual({ ok: false, error: "Invalid request" });
expect(triggerSense).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// list-senses: valid request → ok: true with senses array
// ---------------------------------------------------------------------------
describe("daemon-ipc — list-senses", () => {
it("responds ok:true with empty senses array when listSenses returns []", async () => {
const listSenses = vi.fn(() => []);
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense: vi.fn(),
listSenses,
});
const resp = await sendRaw(sockPath, { type: "list-senses" });
expect(resp).toEqual({ ok: true, senses: [] });
expect(listSenses).toHaveBeenCalledOnce();
});
it("responds ok:true with senses populated from listSenses", async () => {
const sensesData = [
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTimestamp: 1000,
},
{
name: "disk-usage",
group: "system",
throttle: 30000,
timeout: null,
lastSignalTimestamp: null,
},
];
const listSenses = vi.fn(() => sensesData);
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense: vi.fn(),
listSenses,
});
const resp = await sendRaw(sockPath, { type: "list-senses" });
expect(resp).toEqual({ ok: true, senses: sensesData });
expect(listSenses).toHaveBeenCalledOnce();
});
it("responds ok:false when listSenses throws", async () => {
const listSenses = vi.fn(() => {
throw new Error("internal error");
});
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense: vi.fn(),
listSenses,
});
const resp = await sendRaw(sockPath, { type: "list-senses" });
expect(resp).toEqual({ ok: false, error: "internal error" });
});
it("does NOT call listSenses for trigger-sense requests", async () => {
const listSenses = vi.fn(() => []);
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
triggerSense: vi.fn(),
listSenses,
});
await sendRaw(sockPath, { type: "trigger-sense", sense: "cpu-usage" });
expect(listSenses).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
/**
* Phase 3 — FileWatcher workflow change detection tests.
*
* Verifies that file-watcher.ts detects .ts file changes under workflows/.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createFileWatcher } from "../file-watcher.js";
import type { FileChange, FileWatcher } from "../file-watcher.js";
function makeTempNerveRoot(): string {
const dir = mkdtempSync(join(tmpdir(), "nerve-fw-wf-test-"));
mkdirSync(join(dir, "workflows", "my-workflow"), { recursive: true });
writeFileSync(join(dir, "nerve.yaml"), "senses: {}\nreflexes: []\n");
writeFileSync(
join(dir, "workflows", "my-workflow", "index.ts"),
"export default { roles: {}, moderate: () => null };",
);
return dir;
}
async function waitFor(
predicate: () => boolean,
timeoutMs: number,
intervalMs = 50,
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`waitFor timed out after ${timeoutMs}ms`)),
timeoutMs,
);
const check = setInterval(() => {
if (predicate()) {
clearTimeout(timer);
clearInterval(check);
resolve();
}
}, intervalMs);
});
}
describe("createFileWatcher — workflow file changes (Phase 3)", () => {
let watcher: FileWatcher | null = null;
afterEach(() => {
if (watcher !== null) {
watcher.close();
watcher = null;
}
});
it("detects workflow .ts file changes and emits kind=workflow", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
watcher = createFileWatcher(root, (change) => changes.push(change), 50);
await new Promise((r) => setTimeout(r, 100));
writeFileSync(
join(root, "workflows", "my-workflow", "index.ts"),
"export default { roles: {}, moderate: () => null }; // updated",
);
await waitFor(() => changes.some((c) => c.kind === "workflow"), 3000);
const wfChanges = changes.filter((c) => c.kind === "workflow");
expect(wfChanges.length).toBeGreaterThanOrEqual(1);
const wfChange = wfChanges[0] as { workflowName: string; filePath: string };
expect(wfChange.workflowName).toBe("my-workflow");
}, 10_000);
it("does NOT emit workflow change for nerve.yaml", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
watcher = createFileWatcher(root, (change) => changes.push(change), 50);
await new Promise((r) => setTimeout(r, 100));
writeFileSync(join(root, "nerve.yaml"), "senses: {}\nreflexes: []\n# changed\n");
await waitFor(() => changes.some((c) => c.kind === "config"), 3000);
const wfChanges = changes.filter((c) => c.kind === "workflow");
expect(wfChanges).toHaveLength(0);
}, 10_000);
it("debounces rapid workflow file changes", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
watcher = createFileWatcher(root, (change) => changes.push(change), 200);
await new Promise((r) => setTimeout(r, 100));
for (let i = 0; i < 5; i++) {
writeFileSync(
join(root, "workflows", "my-workflow", "index.ts"),
`export default {}; // v${i}`,
);
}
await waitFor(() => changes.some((c) => c.kind === "workflow"), 3000);
// Allow debounce window to pass
await new Promise((r) => setTimeout(r, 300));
const wfChanges = changes.filter((c) => c.kind === "workflow");
expect(wfChanges.length).toBe(1);
}, 10_000);
it("cleans up temp dir after test", () => {
const root = makeTempNerveRoot();
rmSync(root, { recursive: true, force: true });
});
});
@@ -0,0 +1,126 @@
/**
* Unit tests for file-watcher.ts
*/
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createFileWatcher } from "../file-watcher.js";
import type { FileChange, FileWatcher } from "../file-watcher.js";
function makeTempNerveRoot(): string {
const dir = mkdtempSync(join(tmpdir(), "nerve-fw-test-"));
mkdirSync(join(dir, "senses", "cpu-usage"), { recursive: true });
writeFileSync(join(dir, "nerve.yaml"), "senses: {}\nreflexes: []\n");
writeFileSync(
join(dir, "senses", "cpu-usage", "index.ts"),
"export async function compute() { return null; }",
);
return dir;
}
async function waitFor(
predicate: () => boolean,
timeoutMs: number,
intervalMs = 50,
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`waitFor timed out after ${timeoutMs}ms`)),
timeoutMs,
);
const check = setInterval(() => {
if (predicate()) {
clearTimeout(timer);
clearInterval(check);
resolve();
}
}, intervalMs);
});
}
describe("createFileWatcher", () => {
let watcher: FileWatcher | null = null;
afterEach(() => {
if (watcher !== null) {
watcher.close();
watcher = null;
}
});
it("detects nerve.yaml changes", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
watcher = createFileWatcher(root, (change) => changes.push(change), 50);
// Wait a bit for watcher to initialize, then modify nerve.yaml
await new Promise((r) => setTimeout(r, 100));
writeFileSync(join(root, "nerve.yaml"), "senses: {}\nreflexes: []\n# changed\n");
await waitFor(() => changes.length > 0, 3000);
expect(changes.length).toBeGreaterThanOrEqual(1);
expect(changes.some((c) => c.kind === "config")).toBe(true);
}, 10_000);
it("detects sense .ts file changes", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
watcher = createFileWatcher(root, (change) => changes.push(change), 50);
await new Promise((r) => setTimeout(r, 100));
writeFileSync(
join(root, "senses", "cpu-usage", "index.ts"),
"export async function compute() { return 42; }",
);
await waitFor(() => changes.length > 0, 3000);
expect(changes.length).toBeGreaterThanOrEqual(1);
const senseChanges = changes.filter((c) => c.kind === "sense");
expect(senseChanges.length).toBeGreaterThanOrEqual(1);
expect((senseChanges[0] as { senseName: string }).senseName).toBe("cpu-usage");
}, 10_000);
it("close() stops the watcher", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
watcher = createFileWatcher(root, (change) => changes.push(change), 50);
await new Promise((r) => setTimeout(r, 100));
watcher.close();
watcher = null;
writeFileSync(join(root, "nerve.yaml"), "senses: {}\nreflexes: []\n# after close\n");
// Wait and verify no changes were captured
await new Promise((r) => setTimeout(r, 500));
expect(changes.length).toBe(0);
}, 5_000);
it("debounces rapid changes", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
watcher = createFileWatcher(root, (change) => changes.push(change), 200);
await new Promise((r) => setTimeout(r, 100));
// Write rapidly
for (let i = 0; i < 5; i++) {
writeFileSync(join(root, "nerve.yaml"), `senses: {}\nreflexes: []\n# v${i}\n`);
}
await waitFor(() => changes.length > 0, 3000);
// With debounce, should see only 1 change (not 5)
expect(changes.length).toBe(1);
}, 10_000);
});
@@ -0,0 +1,36 @@
/**
* Mock worker that crashes on first compute, then works normally after respawn.
* Uses a marker file to track if it has already crashed.
*
* First invocation: sends ready, then exits with code 1 on first compute.
* Subsequent invocations (after respawn): works like normal mock-worker.
*/
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const markerFile = join(tmpdir(), `nerve-crash-once-${process.ppid}.marker`);
process.send({ type: "ready" });
const hasCrashed = existsSync(markerFile);
process.on("message", (msg) => {
if (!msg || typeof msg !== "object") return;
if (msg.type === "shutdown") {
try {
unlinkSync(markerFile);
} catch {}
process.exit(0);
}
if (msg.type === "compute" && typeof msg.sense === "string") {
if (!hasCrashed) {
writeFileSync(markerFile, "crashed", "utf8");
process.exit(1);
}
process.send({ type: "signal", sense: msg.sense, payload: 42 });
}
});
@@ -0,0 +1,18 @@
/**
* Mock worker that responds to compute with an error message.
* Used for error isolation integration tests.
*/
process.send({ type: "ready" });
process.on("message", (msg) => {
if (!msg || typeof msg !== "object") return;
if (msg.type === "shutdown") {
process.exit(0);
}
if (msg.type === "compute" && typeof msg.sense === "string") {
process.send({ type: "error", sense: msg.sense, error: "simulated compute error" });
}
});
@@ -0,0 +1,24 @@
/**
* Mock worker that takes a long time to respond to compute messages.
* Used for grace period / timeout integration tests.
*
* On compute: waits 10 seconds before responding (simulates hung compute).
* On shutdown: exits cleanly.
*/
process.send({ type: "ready" });
process.on("message", (msg) => {
if (!msg || typeof msg !== "object") return;
if (msg.type === "shutdown") {
process.exit(0);
}
if (msg.type === "compute" && typeof msg.sense === "string") {
// Intentionally slow — will be killed by grace period
setTimeout(() => {
process.send({ type: "signal", sense: msg.sense, payload: "late" });
}, 10_000);
}
});
@@ -0,0 +1,376 @@
/**
* Phase 3 — Hot reload tests.
*
* Verifies that:
* - drainAndRespawn() sends shutdown, waits for exit, then respawns the worker
* - Kernel dispatches handleWorkflowFileChange when file-watcher emits a workflow change
* - Kernel logs a workflow_reload system event on hot reload
* - drainAndRespawn on a non-existent worker is a no-op
* - drainAndRespawn after the drain sends a fresh worker (not crash-recovery)
*/
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockChild = EventEmitter & {
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
connected: boolean;
exitCode: number | null;
pid: number;
};
const mockChildren: MockChild[] = [];
function makeMockChild(pid = 1): MockChild {
const child = new EventEmitter() as MockChild;
child.connected = true;
child.exitCode = null;
child.pid = pid;
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "shutdown"
) {
setImmediate(() => {
child.exitCode = 0;
child.connected = false;
child.emit("exit", 0, null);
});
}
});
child.kill = vi.fn((_signal?: string) => {
child.exitCode = 1;
child.connected = false;
child.emit("exit", null, _signal ?? "SIGKILL");
});
return child;
}
vi.mock("node:child_process", () => ({
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
const child = makeMockChild(mockChildren.length + 1);
mockChildren.push(child);
return child;
}),
}));
const { createWorkflowManager } = await import("../workflow-manager.js");
const { createKernel } = await import("../kernel.js");
function makeWfConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig {
return { senses: {}, reflexes: [], workflows, maxRounds: 10 };
}
function makeLogStore() {
return {
append: vi.fn(),
query: vi.fn(() => []),
getMeta: vi.fn(() => null),
setMeta: vi.fn(),
upsertWorkflowRun: vi.fn(),
appendWithWorkflowUpdate: vi.fn(),
getWorkflowRun: vi.fn(() => null),
getActiveWorkflowRuns: vi.fn(() => []),
getTriggerPayload: vi.fn(() => null),
getThreadEvents: vi.fn(() => []),
getThreadMessages: vi.fn(() => []),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
getAllWorkflowRuns: vi.fn(() => []),
};
}
describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("drainAndRespawn does NOT respawn when workflow is removed from config", async () => {
const logStore = makeLogStore();
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
// Remove workflow from config before drain completes
mgr.updateConfig(makeWfConfig({}));
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
await drainPromise;
// No new worker should have been spawned (workflow was removed)
expect(mockChildren).toHaveLength(1);
});
it("drainAndRespawn marks in-flight runs as interrupted in DB", async () => {
const logStore = makeLogStore();
const config = makeWfConfig({ "my-wf": { concurrency: 2, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
await drainPromise;
const interruptedCalls = logStore.upsertWorkflowRun.mock.calls.filter(
(args: any[]) => (args[0] as { type: string }).type === "interrupted",
);
expect(interruptedCalls).toHaveLength(2);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("drainAndRespawn on an unknown workflow (no worker) resolves immediately", async () => {
const logStore = makeLogStore();
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// No thread started — no worker spawned
await expect(mgr.drainAndRespawn("my-wf")).resolves.toBeUndefined();
});
it("drainAndRespawn sends shutdown to existing worker and waits for exit", async () => {
const logStore = makeLogStore();
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
await drainPromise;
const firstChild = mockChildren[0];
expect(firstChild.send).toHaveBeenCalledWith(expect.objectContaining({ type: "shutdown" }));
});
it("drainAndRespawn spawns a fresh worker after the old one exits", async () => {
const logStore = makeLogStore();
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
await drainPromise;
// A new worker should have been spawned (not crash-recovery, just fresh)
expect(mockChildren).toHaveLength(2);
});
it("fresh worker after drainAndRespawn does NOT receive resume-thread messages", async () => {
const logStore = makeLogStore();
// Even if there are active runs in DB, after drain the worker should NOT get resume
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
await drainPromise;
const newChild = mockChildren[1];
const resumeCalls = (newChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
(args: any[]) =>
args[0] !== null &&
typeof args[0] === "object" &&
(args[0] as Record<string, unknown>).type === "resume-thread",
);
expect(resumeCalls).toHaveLength(0);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("new threads can be started on the fresh worker after drainAndRespawn", async () => {
const logStore = makeLogStore();
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "first", maxRounds: 10, dryRun: false });
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
await drainPromise;
// Start a new thread on the fresh worker
mgr.startWorkflow("my-wf", { prompt: "second", maxRounds: 10, dryRun: false });
const newChild = mockChildren[1];
const startCalls = (newChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
(args: any[]) =>
args[0] !== null &&
typeof args[0] === "object" &&
(args[0] as Record<string, unknown>).type === "start-thread",
);
expect(startCalls).toHaveLength(1);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-hot-reload-"));
});
afterEach(async () => {
vi.useRealTimers();
vi.clearAllMocks();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("handleWorkflowFileChange logs workflow_reload system event", async () => {
const logStore = makeLogStore();
const config: NerveConfig = {
senses: {},
reflexes: [],
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Trigger a workflow thread so a worker is spawned
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
// Manually call drainAndRespawn (simulating what kernel does on workflow file change)
const drainPromise = kernel.workflowManager.drainAndRespawn("my-wf", 1000);
await vi.runAllTimersAsync();
await drainPromise;
// Kernel's handleWorkflowFileChange should log a workflow_reload event
// We test this via the kernel itself
const appendCalls = logStore.append.mock.calls;
const startCall = appendCalls.find(
(args: any[]) => (args[0] as { type: string }).type === "start",
);
expect(startCall).toBeDefined();
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("reloadConfig drains worker for removed workflows", async () => {
const logStore = makeLogStore();
const initialConfig: NerveConfig = {
senses: {},
reflexes: [],
workflows: { "old-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
const kernel = createKernel(initialConfig, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Spawn a worker for old-wf
kernel.workflowManager.startWorkflow("old-wf", {
prompt: "test",
maxRounds: 10,
dryRun: false,
});
expect(mockChildren).toHaveLength(1);
// Reload config without old-wf
const newConfig: NerveConfig = {
senses: {},
reflexes: [],
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
await vi.runAllTimersAsync();
// The old worker should have received a shutdown (drain)
expect(mockChildren[0].send).toHaveBeenCalledWith(
expect.objectContaining({ type: "shutdown" }),
);
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("reloadConfig updates concurrency/overflow without restarting worker", async () => {
const logStore = makeLogStore();
const initialConfig: NerveConfig = {
senses: {},
reflexes: [],
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
const kernel = createKernel(initialConfig, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const workersBefore = mockChildren.length;
// Reload with updated concurrency — should NOT spawn a new workflow worker
const newConfig: NerveConfig = {
senses: {},
reflexes: [],
workflows: { "my-wf": { concurrency: 5, overflow: "queue", maxQueue: 50 } },
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
// No extra workflow worker spawn (the config update is in-place)
// The worker count may increase if senses change, but the workflow worker should not be respawned
expect(mockChildren).toHaveLength(workersBefore);
// After reload, the new concurrency should be respected
expect(kernel.workflowManager.activeCount("my-wf")).toBe(1);
// Can now start up to 5 concurrent threads (previously only 1)
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(kernel.workflowManager.activeCount("my-wf")).toBe(3);
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
@@ -6,12 +6,14 @@
* artifacts are required.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Signal } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createKernel } from "../kernel.js";
import type { Kernel } from "../kernel.js";
@@ -26,7 +28,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
}
@@ -54,12 +57,18 @@ async function pollUntil(
describe("kernel integration — real child processes", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-integration-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("returns correct groups and senseCount", () => {
@@ -70,7 +79,7 @@ describe("kernel integration — real child processes", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
});
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -82,7 +91,7 @@ describe("kernel integration — real child processes", () => {
it("workers start and respond to compute messages with signals", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -114,7 +123,7 @@ describe("kernel integration — real child processes", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
});
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -130,7 +139,7 @@ describe("kernel integration — real child processes", () => {
it("compute round-trip: worker receives compute and sends signal back through bus", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -157,7 +166,7 @@ describe("kernel integration — real child processes", () => {
it("crash recovery: kernel respawns worker after unexpected exit and new worker is functional", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -0,0 +1,261 @@
/**
* Unit tests for kernel Phase 6 enhancements — restartGroup, reloadConfig, getHealth.
* Uses mocked child_process.fork.
*/
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock child_process.fork
// ---------------------------------------------------------------------------
const mockChildren: MockChild[] = [];
type MockChild = EventEmitter & {
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
pid: number;
connected: boolean;
};
function makeMockChild(pid = 1): MockChild {
const child = new EventEmitter() as MockChild;
child.connected = true;
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "shutdown"
) {
setImmediate(() => {
child.connected = false;
child.emit("exit", 0, null);
});
}
});
child.kill = vi.fn((_signal?: string) => {
child.connected = false;
child.emit("exit", null, _signal ?? "SIGKILL");
});
child.pid = pid;
// Auto-emit ready so restartGroup's await resolves
setImmediate(() => {
child.emit("message", { type: "ready" });
});
return child;
}
vi.mock("node:child_process", () => ({
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
const child = makeMockChild(mockChildren.length + 100);
mockChildren.push(child);
return child;
}),
}));
// Must also mock file-watcher since kernel imports it
vi.mock("../file-watcher.js", () => ({
createFileWatcher: vi.fn(() => ({ close: vi.fn() })),
}));
const { createKernel } = await import("../kernel.js");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("kernel — getHealth", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-health-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("returns correct health shape", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
});
const kernel = createKernel(config, nerveRoot);
const health = kernel.getHealth();
expect(health.activeSenses).toBe(3);
expect(health.activeGroups).toBe(2);
expect(health.uptime).toBeGreaterThanOrEqual(0);
expect(health.memoryUsage).toBeDefined();
expect(typeof health.memoryUsage.heapUsed).toBe("number");
await kernel.stop();
});
});
describe("kernel — restartGroup", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-restart-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("sends shutdown to old worker and spawns new one", async () => {
const config = makeConfig();
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(1);
const oldChild = mockChildren[0];
const restartPromise = kernel.restartGroup("system");
// The shutdown message triggers exit in the mock
await restartPromise;
// A new child should have been spawned
expect(mockChildren.length).toBe(2);
// Old child got shutdown
expect(oldChild.send).toHaveBeenCalledWith(expect.objectContaining({ type: "shutdown" }));
await kernel.stop();
});
it("restartGroup on unknown group does nothing", async () => {
const config = makeConfig();
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(1);
await kernel.restartGroup("nonexistent");
// No new child spawned
expect(mockChildren.length).toBe(1);
await kernel.stop();
});
});
describe("kernel — reloadConfig", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-reload-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("adds new group worker when new sense group appears", async () => {
const config = makeConfig();
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(1); // only system group
expect(kernel.groups.has("network")).toBe(false);
kernel.reloadConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
});
expect(kernel.groups.has("network")).toBe(true);
expect(mockChildren.length).toBe(2); // system + network
await kernel.stop();
});
it("removes group worker when its senses are all removed", async () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
};
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(2);
expect(kernel.groups.has("network")).toBe(true);
const networkChild = mockChildren[1];
kernel.reloadConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
});
expect(kernel.groups.has("network")).toBe(false);
// Network child should have received shutdown
expect(networkChild.send).toHaveBeenCalledWith(expect.objectContaining({ type: "shutdown" }));
await kernel.stop();
});
it("health reflects updated sense count after reloadConfig", async () => {
const config = makeConfig();
const kernel = createKernel(config, nerveRoot);
expect(kernel.getHealth().activeSenses).toBe(1);
kernel.reloadConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
});
expect(kernel.getHealth().activeSenses).toBe(2);
await kernel.stop();
});
});
@@ -0,0 +1,209 @@
/**
* Unit tests for kernel.triggerSense() — IPC issue #36.
*
* These tests use a mock child_process and a mock LogStore so they do NOT
* require a real LogStore (node:sqlite) in integration tests.
*/
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock child_process.fork before importing kernel
// ---------------------------------------------------------------------------
const mockChildren: MockChild[] = [];
type MockChild = EventEmitter & {
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
connected: boolean;
pid: number;
};
function makeMockChild(pid = 1): MockChild {
const child = new EventEmitter() as MockChild;
child.connected = true;
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "shutdown"
) {
setImmediate(() => {
child.connected = false;
child.emit("exit", 0, null);
});
}
});
child.kill = vi.fn((_signal?: string) => {
child.connected = false;
child.emit("exit", null, _signal ?? "SIGKILL");
});
child.pid = pid;
return child;
}
vi.mock("node:child_process", () => ({
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
const child = makeMockChild(mockChildren.length + 1);
mockChildren.push(child);
return child;
}),
}));
// Import after mock is set up
const { createKernel } = await import("../kernel.js");
// ---------------------------------------------------------------------------
// Mock LogStore factory (avoids SQLite I/O in this unit test)
// ---------------------------------------------------------------------------
function makeMockLogStore() {
return {
append: vi.fn(),
query: vi.fn(() => []),
getMeta: vi.fn(() => null),
setMeta: vi.fn(),
upsertWorkflowRun: vi.fn(),
appendWithWorkflowUpdate: vi.fn(),
getWorkflowRun: vi.fn(() => null),
getActiveWorkflowRuns: vi.fn(() => []),
getAllWorkflowRuns: vi.fn(() => []),
getTriggerPayload: vi.fn(() => null),
getThreadEvents: vi.fn(() => []),
getThreadMessages: vi.fn(() => []),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
};
}
// ---------------------------------------------------------------------------
// Config helpers
// ---------------------------------------------------------------------------
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("kernel.triggerSense()", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-trigger-sense-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("throws for an unknown sense name", async () => {
const config = makeConfig();
const kernel = createKernel(config, nerveRoot, {
workerScript: null,
logStore: makeMockLogStore() as never,
});
expect(() => kernel.triggerSense("no-such-sense")).toThrow(/Unknown sense/);
await kernel.stop();
});
it("sends a compute message to the worker for the correct group", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-io": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
});
const kernel = createKernel(config, nerveRoot, {
workerScript: null,
logStore: makeMockLogStore() as never,
});
// Two groups → two workers
expect(mockChildren.length).toBe(2);
// Workers are keyed by group: groups iteration order matches the insertion
// order from Object.values(config.senses). Find the worker for "system".
const systemWorkerIdx = Array.from(kernel.groups).indexOf("system");
const systemWorker = mockChildren[systemWorkerIdx];
kernel.triggerSense("cpu-usage");
expect(systemWorker.send).toHaveBeenCalledWith(
expect.objectContaining({ type: "compute", sense: "cpu-usage" }),
);
await kernel.stop();
});
it("sends a compute message to the correct worker when multiple senses share a group", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
});
const kernel = createKernel(config, nerveRoot, {
workerScript: null,
logStore: makeMockLogStore() as never,
});
// Both senses share the "system" group → one worker only
expect(mockChildren.length).toBe(1);
const worker = mockChildren[0];
kernel.triggerSense("disk-usage");
expect(worker.send).toHaveBeenCalledWith(
expect.objectContaining({ type: "compute", sense: "disk-usage" }),
);
await kernel.stop();
});
it("does not send to a disconnected worker (does not throw)", async () => {
// Use real timers so kernel.stop() waitForExit can rely on SIGKILL timeout
vi.useRealTimers();
const config = makeConfig();
const kernel = createKernel(config, nerveRoot, {
workerScript: null,
logStore: makeMockLogStore() as never,
});
const worker = mockChildren[0];
worker.connected = false;
// Should not throw even when the worker is disconnected
expect(() => kernel.triggerSense("cpu-usage")).not.toThrow();
expect(worker.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "compute" }));
await kernel.stop();
}, 10_000);
});
@@ -0,0 +1,487 @@
/**
* Integration tests for Kernel + WorkflowManager integration.
*
* Verifies that sense signals trigger workflow runs via workflow reflexes,
* that workflow events are logged, that reloadConfig handles workflow changes,
* and that graceful shutdown stops workflow workers.
*
* Uses mocked child_process.fork to avoid real subprocesses.
*/
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock child_process.fork before importing kernel
// ---------------------------------------------------------------------------
type MockChild = EventEmitter & {
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
connected: boolean;
exitCode: number | null;
pid: number;
};
const mockChildren: MockChild[] = [];
function makeMockChild(pid = 1): MockChild {
const child = new EventEmitter() as MockChild;
child.connected = true;
child.exitCode = null;
child.pid = pid;
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "shutdown"
) {
setImmediate(() => {
child.exitCode = 0;
child.connected = false;
child.emit("exit", 0, null);
});
}
});
child.kill = vi.fn((_signal?: string) => {
child.exitCode = 1;
child.connected = false;
child.emit("exit", null, _signal ?? "SIGKILL");
});
return child;
}
vi.mock("node:child_process", () => ({
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
const child = makeMockChild(mockChildren.length + 1);
mockChildren.push(child);
return child;
}),
}));
// Import after mock is set up
const { createKernel } = await import("../kernel.js");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeLogStore() {
return {
append: vi.fn(),
query: vi.fn(() => []),
getMeta: vi.fn(() => null),
setMeta: vi.fn(),
upsertWorkflowRun: vi.fn(),
appendWithWorkflowUpdate: vi.fn(),
getWorkflowRun: vi.fn(() => null),
getActiveWorkflowRuns: vi.fn(() => []),
getAllWorkflowRuns: vi.fn(() => []),
getTriggerPayload: vi.fn(() => null),
getThreadEvents: vi.fn(() => []),
getThreadMessages: vi.fn(() => []),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
};
}
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("kernel + workflowManager integration", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-wf-"));
});
afterEach(async () => {
vi.useRealTimers();
vi.clearAllMocks();
rmSync(nerveRoot, { recursive: true, force: true });
});
describe("sense compute triggers workflow via return value", () => {
it("calls workflowManager.startWorkflow when a sense compute returns a workflow launch", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: { "my-workflow": { concurrency: 2, overflow: "drop" } },
});
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Simulate a sense worker sending a signal with workflow launch payload
// The kernel's handleWorkerMessage processes "signal" type messages
// and uses routeSenseComputeOutput to detect workflow launches
const workerPool = mockChildren[0];
if (workerPool) {
// Simulate the worker sending a signal message with workflow field
workerPool.emit("message", {
type: "signal",
sense: "cpu-usage",
payload: { workflow: "my-workflow|10|run this workflow" },
});
}
// A workflow worker should be spawned and a start-thread message sent
const workflowWorker = mockChildren.find((c) =>
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
(args: unknown[]) =>
args[0] !== null &&
typeof args[0] === "object" &&
(args[0] as Record<string, unknown>).type === "start-thread",
),
);
expect(workflowWorker).toBeDefined();
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("passes prompt and maxRounds from the workflow field to the workflow", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: { "alert-workflow": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Simulate sense worker returning a workflow launch
const workerPool = mockChildren[0];
if (workerPool) {
workerPool.emit("message", {
type: "signal",
sense: "cpu-usage",
payload: { workflow: "alert-workflow|5|handle critical alert" },
});
}
// Find the start-thread call and verify triggerPayload
const startThreadCall = mockChildren
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
.find(
([msg]) =>
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "start-thread",
);
expect(startThreadCall).toBeDefined();
expect(startThreadCall?.[0]).toMatchObject({
type: "start-thread",
workflow: "alert-workflow",
prompt: "handle critical alert",
maxRounds: 5,
dryRun: false,
});
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("does not trigger workflow when signal senseId is not in 'on' list", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-io": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: { "my-workflow": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Emit a regular signal (no workflow field) — should NOT trigger any workflow
const workerPool = mockChildren[0];
if (workerPool) {
workerPool.emit("message", {
type: "signal",
sense: "cpu-usage",
payload: 50,
});
}
// No workflow should have been started
const workflowWorkerSpawned = mockChildren.some((c) =>
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
(args: unknown[]) =>
args[0] !== null &&
typeof args[0] === "object" &&
(args[0] as Record<string, unknown>).type === "start-thread",
),
);
expect(workflowWorkerSpawned).toBe(false);
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("workflow events are logged", () => {
it("logs a 'started' event when workflow thread is triggered via sense compute", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } },
});
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Simulate sense compute returning a workflow launch
const workerPool = mockChildren[0];
if (workerPool) {
workerPool.emit("message", {
type: "signal",
sense: "cpu-usage",
payload: { workflow: "log-test-workflow|10|test prompt" },
});
}
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
expect.objectContaining({ source: "workflow", type: "started" }),
expect.objectContaining({ workflow: "log-test-workflow", status: "started" }),
);
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("reloadConfig handles workflow changes", () => {
it("new workflows are available after reloadConfig", async () => {
const logStore = makeLogStore();
const initialConfig = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
});
const kernel = createKernel(initialConfig, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Reload with a workflow added
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: { "new-workflow": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
// Simulate sense compute returning a workflow launch for the new workflow
const workerPool = mockChildren[0];
if (workerPool) {
workerPool.emit("message", {
type: "signal",
sense: "cpu-usage",
payload: { workflow: "new-workflow|10|reload test" },
});
}
const startThreadCall = mockChildren
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
.find(
([msg]) =>
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "start-thread" &&
(msg as Record<string, unknown>).workflow === "new-workflow",
);
expect(startThreadCall).toBeDefined();
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
it("old workflows are removed after reloadConfig", async () => {
const logStore = makeLogStore();
const initialConfig = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: { "old-workflow": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(initialConfig, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Reload with the workflow removed
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
// Clear send history
for (const c of mockChildren) {
(c.send as ReturnType<typeof vi.fn>).mockClear();
}
// Simulate sense compute trying to launch the old workflow — it should still not start
const workerPool = mockChildren[0];
if (workerPool) {
workerPool.emit("message", {
type: "signal",
sense: "cpu-usage",
payload: { workflow: "old-workflow|10|should not work" },
});
}
const startThreadCall = mockChildren
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
.find(
([msg]) =>
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "start-thread",
);
expect(startThreadCall).toBeUndefined();
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("graceful shutdown stops workflow workers", () => {
it("stop() resolves after workflow workers exit", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: { "shutdown-test": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// Trigger a workflow via sense compute return value
const workerPool = mockChildren[0];
if (workerPool) {
workerPool.emit("message", {
type: "signal",
sense: "cpu-usage",
payload: { workflow: "shutdown-test|10|test" },
});
}
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await expect(stopPromise).resolves.toBeUndefined();
});
it("workflowManager is exposed on kernel", () => {
const logStore = makeLogStore();
const config = makeConfig({
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
expect(kernel.workflowManager).toBeDefined();
expect(typeof kernel.workflowManager.startWorkflow).toBe("function");
expect(typeof kernel.workflowManager.activeCount).toBe("function");
expect(typeof kernel.workflowManager.stop).toBe("function");
kernel.stop().catch(() => {});
});
it("getHealth includes activeWorkflows count", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: { "health-wf": { concurrency: 2, overflow: "drop" } },
});
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
const health = kernel.getHealth();
expect(health).toHaveProperty("activeWorkflows");
expect(typeof health.activeWorkflows).toBe("number");
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
});
+48 -9
View File
@@ -1,4 +1,7 @@
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -44,6 +47,7 @@ vi.mock("node:child_process", () => ({
// Import after mock is set up
const { createKernel } = await import("../kernel.js");
const { createLogStore } = await import("@uncaged/nerve-store");
// ---------------------------------------------------------------------------
// Helpers
@@ -55,7 +59,8 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
}
@@ -65,13 +70,17 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
// ---------------------------------------------------------------------------
describe("kernel — message routing", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-msg-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("routes signal message to bus without throwing", async () => {
@@ -81,7 +90,7 @@ describe("kernel — message routing", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(1);
const child = mockChildren[0];
@@ -93,6 +102,29 @@ describe("kernel — message routing", () => {
await kernel.stop();
});
it("persists emitted signals as sense/signal log entries", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "nerve-kernel-sig-"));
const logStore = createLogStore(join(tmpDir, "logs.db"));
try {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
});
const kernel = createKernel(config, tmpDir, { logStore });
const child = mockChildren[0];
child.emit("message", { type: "ready" });
child.emit("message", { type: "signal", sense: "cpu-usage", payload: 123 });
const rows = logStore.query({ source: "sense", type: "signal", refId: "cpu-usage" });
expect(rows).toHaveLength(1);
expect(rows[0].payload).toBe(JSON.stringify(123));
await kernel.stop();
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it("routes error message to stderr", async () => {
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const config = makeConfig({
@@ -101,7 +133,7 @@ describe("kernel — message routing", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
const child = mockChildren[0];
child.emit("message", { type: "error", sense: "cpu-usage", error: "compute failed" });
@@ -122,7 +154,7 @@ describe("kernel — message routing", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
const child = mockChildren[0];
const callsBefore = stderrSpy.mock.calls.length;
@@ -142,7 +174,7 @@ describe("kernel — message routing", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
const child = mockChildren[0];
expect(() => child.emit("message", { type: "unknown-type" })).not.toThrow();
@@ -155,13 +187,17 @@ describe("kernel — message routing", () => {
});
describe("kernel — groupForSense mapping", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-groups-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("spawns one worker per unique group", async () => {
@@ -172,9 +208,10 @@ describe("kernel — groupForSense mapping", () => {
"net-usage": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
// system and network = 2 unique groups
expect(mockChildren.length).toBe(2);
@@ -186,9 +223,9 @@ describe("kernel — groupForSense mapping", () => {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: null }],
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: [] }],
});
createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
const child = mockChildren[0];
vi.advanceTimersByTime(500);
@@ -196,5 +233,7 @@ describe("kernel — groupForSense mapping", () => {
expect(child.send).toHaveBeenCalledWith(
expect.objectContaining({ type: "compute", sense: "cpu-usage" }),
);
await kernel.stop();
});
});
@@ -0,0 +1,120 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NerveConfig, Signal } from "@uncaged/nerve-core";
import { createLogStore } from "@uncaged/nerve-store";
import type { LogStore } from "@uncaged/nerve-store";
import { createReflexScheduler } from "../reflex-scheduler.js";
import { createSignalBus } from "../signal-bus.js";
describe("LogStore + ReflexScheduler integration", () => {
let tmpDir: string;
let logStore: LogStore;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-log-int-"));
logStore = createLogStore(join(tmpDir, "logs.db"));
});
afterEach(() => {
logStore.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("logs run_start when reflex triggers a compute", () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
const triggered: string[] = [];
const scheduler = createReflexScheduler(config, bus, (name) => triggered.push(name), {
logStore,
});
const signal: Signal = { id: 1, senseId: "cpu-usage", payload: 42, timestamp: Date.now() };
bus.emit(signal);
const logs = logStore.query({ source: "reflex", type: "run_start" });
expect(logs).toHaveLength(1);
expect(logs[0].refId).toBe("cpu-usage");
expect(triggered).toHaveLength(1);
scheduler.stop();
});
it("interval reflex produces run_start logs", () => {
vi.useFakeTimers();
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
const ref: { scheduler: ReturnType<typeof createReflexScheduler> | null } = { scheduler: null };
const scheduler = createReflexScheduler(
config,
bus,
(name) => {
ref.scheduler?.onComputeComplete(name);
},
{ logStore },
);
ref.scheduler = scheduler;
vi.advanceTimersByTime(3000);
const logs = logStore.query({ source: "reflex", type: "run_start" });
expect(logs.length).toBeGreaterThanOrEqual(3);
expect(logs.every((l) => l.refId === "cpu-usage")).toBe(true);
scheduler.stop();
vi.useRealTimers();
});
it("logs cannot trigger reflexes (architectural constraint)", () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
const triggered: string[] = [];
const scheduler = createReflexScheduler(
config,
bus,
(name) => {
triggered.push(name);
scheduler.onComputeComplete(name);
},
{ logStore },
);
logStore.append({
source: "reflex",
type: "run_complete",
refId: "cpu-usage",
payload: '{"v":99}',
timestamp: Date.now(),
});
// Writing to the log store should NOT trigger any reflex.
// Only bus.emit(signal) triggers reflexes.
expect(triggered).toHaveLength(0);
scheduler.stop();
});
});
@@ -0,0 +1,399 @@
/**
* Phase 6 integration tests — hot reload, error isolation, grace period, health.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Signal } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createKernel } from "../kernel.js";
import type { Kernel } from "../kernel.js";
const __filename = fileURLToPath(import.meta.url);
const __dir = dirname(__filename);
const MOCK_WORKER = join(__dir, "fixtures", "mock-worker.mjs");
const ERROR_WORKER = join(__dir, "fixtures", "error-worker.mjs");
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
...overrides,
};
}
async function pollUntil(
predicate: () => boolean,
timeoutMs: number,
intervalMs = 50,
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`pollUntil timed out after ${timeoutMs}ms`)),
timeoutMs,
);
const check = setInterval(() => {
if (predicate()) {
clearTimeout(timer);
clearInterval(check);
resolve();
}
}, intervalMs);
});
}
// ---------------------------------------------------------------------------
// Hot Reload — restartGroup
// ---------------------------------------------------------------------------
describe("phase6 — restartGroup", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-restart-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("restartGroup stops old worker and spawns a new one", async () => {
const config = makeConfig();
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
const oldPid = kernel.getWorkerPid("system");
expect(oldPid).not.toBeNull();
await kernel.restartGroup("system");
// Wait for new worker to become ready
await pollUntil(() => {
const newPid = kernel?.getWorkerPid("system");
return newPid !== null && newPid !== oldPid;
}, 5000);
const newPid = kernel.getWorkerPid("system");
expect(newPid).not.toBeNull();
expect(newPid).not.toBe(oldPid);
// Verify new worker is functional
const received: Signal[] = [];
const unsub = kernel.bus.subscribe((s) => received.push(s));
kernel.triggerCompute("cpu-usage");
await pollUntil(() => received.length > 0, 3000);
expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
unsub();
}, 15_000);
it("restartGroup on nonexistent group does nothing", async () => {
const config = makeConfig();
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
// Should not throw
await kernel.restartGroup("nonexistent");
}, 5_000);
});
// ---------------------------------------------------------------------------
// Hot Reload — reloadConfig
// ---------------------------------------------------------------------------
describe("phase6 — reloadConfig", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-reload-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("adds new group when new sense group is introduced", async () => {
const config = makeConfig();
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
expect(kernel.groups.has("network")).toBe(false);
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
expect(kernel.groups.has("network")).toBe(true);
// Wait for the new network worker to start
await pollUntil(() => kernel?.getWorkerPid("network") !== null, 3000);
expect(kernel.getWorkerPid("network")).not.toBeNull();
}, 10_000);
it("removes group when all its senses are removed", async () => {
const config: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
};
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
expect(kernel.groups.has("network")).toBe(true);
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
expect(kernel.groups.has("network")).toBe(false);
}, 10_000);
});
// ---------------------------------------------------------------------------
// Error Isolation
// ---------------------------------------------------------------------------
describe("phase6 — error isolation", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-err-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("error from one sense does not crash the worker — other senses still work", async () => {
const config: NerveConfig = {
senses: {
"good-sense": { group: "mixed", throttle: null, timeout: null, gracePeriod: null },
"bad-sense": { group: "mixed", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
};
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
// Both senses go through the same worker (mock-worker responds to all compute with signal)
const received: Signal[] = [];
const unsub = kernel.bus.subscribe((s) => received.push(s));
kernel.triggerCompute("good-sense");
await pollUntil(() => received.length > 0, 3000);
expect(received[0]).toMatchObject({ senseId: "good-sense" });
kernel.triggerCompute("bad-sense");
await pollUntil(() => received.length > 1, 3000);
expect(received[1]).toMatchObject({ senseId: "bad-sense" });
unsub();
}, 10_000);
it("error worker sends error messages, kernel still running", async () => {
const stderrMessages: string[] = [];
const stderrSpy = ((original) => {
return (chunk: string | Uint8Array) => {
stderrMessages.push(String(chunk));
return original.call(process.stderr, chunk);
};
})(process.stderr.write);
const origWrite = process.stderr.write;
process.stderr.write = stderrSpy as typeof process.stderr.write;
const config = makeConfig();
kernel = createKernel(config, nerveRoot, {
workerScript: ERROR_WORKER,
});
await kernel.ready;
kernel.triggerCompute("cpu-usage");
// Wait for the error to be logged
await pollUntil(() => stderrMessages.some((m) => m.includes("simulated compute error")), 3000);
process.stderr.write = origWrite;
// Kernel should still be running (not crashed)
expect(kernel.getWorkerPid("system")).not.toBeNull();
}, 10_000);
});
// ---------------------------------------------------------------------------
// getHealth
// ---------------------------------------------------------------------------
describe("phase6 — getHealth", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-health-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("returns health snapshot with correct shape", async () => {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
});
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
const health = kernel.getHealth();
expect(health.uptime).toBeGreaterThanOrEqual(0);
expect(health.activeSenses).toBe(3);
expect(health.activeGroups).toBe(2);
expect(health.memoryUsage).toBeDefined();
expect(typeof health.memoryUsage.heapUsed).toBe("number");
}, 10_000);
it("health reflects config changes after reloadConfig", async () => {
const config = makeConfig();
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
expect(kernel.getHealth().activeSenses).toBe(1);
const newConfig: NerveConfig = {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
expect(kernel.getHealth().activeSenses).toBe(2);
expect(kernel.getHealth().activeGroups).toBe(2);
}, 10_000);
});
// ---------------------------------------------------------------------------
// Auto-respawn on crash (existing test extended)
// ---------------------------------------------------------------------------
describe("phase6 — auto-respawn on worker crash", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-crash-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("kernel auto-respawns worker and new worker is functional", async () => {
const config = makeConfig();
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
const originalPid = kernel.getWorkerPid("system");
expect(originalPid).not.toBeNull();
// Kill worker to simulate crash
process.kill(originalPid as number, "SIGKILL");
// Wait for respawn
await pollUntil(() => {
const pid = kernel?.getWorkerPid("system");
return pid !== null && pid !== originalPid;
}, 5000);
const newPid = kernel.getWorkerPid("system");
expect(newPid).not.toBeNull();
expect(newPid).not.toBe(originalPid);
// Verify new worker responds
const received: Signal[] = [];
const unsub = kernel.bus.subscribe((s) => received.push(s));
kernel.triggerCompute("cpu-usage");
await pollUntil(() => received.length > 0, 5000);
expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
unsub();
await kernel.stop();
kernel = null;
}, 15_000);
});
@@ -10,13 +10,14 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
}
function makeSignal(senseId: string, payload: unknown = 1): Signal {
return { id: 1, senseId, payload, ts: Date.now() };
return { id: 1, senseId, payload, timestamp: Date.now() };
}
describe("ReflexScheduler — throttle + pending deferred trigger", () => {
@@ -16,13 +16,14 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"system-health": { group: "derived", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
}
function makeSignal(senseId: string, payload: unknown = 1): Signal {
return { id: 1, senseId, payload, ts: Date.now() };
return { id: 1, senseId, payload, timestamp: Date.now() };
}
// ---------------------------------------------------------------------------
@@ -40,7 +41,7 @@ describe("ReflexScheduler — interval reflex", () => {
it("fires triggerFn on schedule", () => {
const triggered: string[] = [];
const config = makeConfig({
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
});
const bus = createSignalBus();
// Use a ref so the triggerFn can call back into the scheduler
@@ -65,7 +66,7 @@ describe("ReflexScheduler — interval reflex", () => {
it("stops firing after stop() is called", () => {
const triggered: string[] = [];
const config = makeConfig({
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: null }],
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: [] }],
});
const bus = createSignalBus();
const ref: { scheduler: ReturnType<typeof createReflexScheduler> | null } = {
@@ -88,7 +89,7 @@ describe("ReflexScheduler — interval reflex", () => {
it("starts from current time — does not compensate for past intervals", () => {
const triggered: string[] = [];
const config = makeConfig({
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
});
const bus = createSignalBus();
const scheduler = createReflexScheduler(config, bus, (name) => triggered.push(name));
@@ -290,10 +291,11 @@ describe("ReflexScheduler — workflow reflexes ignored", () => {
it("does not set up any scheduling for workflow kind reflexes", () => {
const triggered: string[] = [];
const config: NerveConfig = {
maxRounds: 10,
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["cpu-usage"] }],
reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["cpu-usage"] } as any],
workflows: {
"my-workflow": { concurrency: 1, overflow: "drop" },
},
@@ -2,14 +2,15 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { DatabaseSync } from "node:sqlite";
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, openPeerDb, openSenseDb, runMigrations } from "../sense-runtime.js";
import type { DrizzleDB, PeerMap, SenseRuntime } from "../sense-runtime.js";
import type { ComputeFn, DrizzleDB, PeerMap, SenseRuntime } from "../sense-runtime.js";
// ---------------------------------------------------------------------------
// Helpers
@@ -48,7 +49,7 @@ const samples = sqliteTable("samples", {
describe("runMigrations", () => {
it("creates table via SQL migration file", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
const result = runMigrations(sqlite, migrationsDir);
@@ -63,7 +64,7 @@ describe("runMigrations", () => {
});
it("runs multiple migrations in lexicographic order", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-multi-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
@@ -80,7 +81,7 @@ describe("runMigrations", () => {
});
it("returns ok when migrations directory is empty", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = makeTempMigrationsDirEmpty();
const result = runMigrations(sqlite, dir);
expect(result.ok).toBe(true);
@@ -88,14 +89,14 @@ describe("runMigrations", () => {
});
it("returns err when migrations directory does not exist", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const result = runMigrations(sqlite, "/nonexistent/path/migrations");
expect(result.ok).toBe(false);
sqlite.close();
});
it("returns err when a migration SQL is invalid", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-bad-sql-"));
writeFileSync(join(dir, "0001_bad.sql"), "NOT VALID SQL !!!;");
const result = runMigrations(sqlite, dir);
@@ -140,7 +141,7 @@ describe("openPeerDb", () => {
it("opens an existing db in read-only mode", () => {
// Create a writable db first
const dbPath = makeTempDbPath();
const sqlite = new Database(dbPath);
const sqlite = new DatabaseSync(dbPath);
sqlite.exec(INIT_SQL);
sqlite.prepare("INSERT INTO samples (ts, value) VALUES (1, 42.0)").run();
sqlite.close();
@@ -167,13 +168,13 @@ describe("openPeerDb", () => {
// ---------------------------------------------------------------------------
describe("executeCompute", () => {
function makeRuntime(computeFn: (db: DrizzleDB, peers: PeerMap) => Promise<unknown | null>): {
function makeRuntime(computeFn: ComputeFn): {
runtime: SenseRuntime;
sqlite: Database.Database;
sqlite: DatabaseSync;
} {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
sqlite.exec(INIT_SQL);
const db = drizzle(sqlite) as DrizzleDB;
const db = drizzle({ client: sqlite }) as DrizzleDB;
return {
runtime: { name: "test-sense", db, compute: computeFn },
sqlite,
@@ -225,10 +226,10 @@ describe("executeCompute", () => {
it("compute can read from peers", async () => {
// Set up a peer db with data
const peerSqlite = new Database(":memory:");
const peerSqlite = new DatabaseSync(":memory:");
peerSqlite.exec(INIT_SQL);
peerSqlite.prepare("INSERT INTO samples (ts, value) VALUES (100, 3.14)").run();
const peerDb = drizzle(peerSqlite) as DrizzleDB;
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
const peers: PeerMap = { "other-sense": peerDb };
@@ -247,9 +248,9 @@ describe("executeCompute", () => {
});
it("write to own db does not affect peer db (isolation)", async () => {
const peerSqlite = new Database(":memory:");
const peerSqlite = new DatabaseSync(":memory:");
peerSqlite.exec(INIT_SQL);
const peerDb = drizzle(peerSqlite) as DrizzleDB;
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
const peers: PeerMap = { "peer-sense": peerDb };
const { runtime, sqlite } = makeRuntime(async (db) => {
@@ -340,6 +341,20 @@ describe("executeCompute", () => {
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();
});
});
// ---------------------------------------------------------------------------
@@ -388,7 +403,7 @@ describe("parseParentMessage", () => {
describe("runMigrations journal", () => {
it("does not re-run an already-applied migration", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-journal-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
@@ -415,7 +430,7 @@ describe("runMigrations journal", () => {
});
it("tracks migrations in _migrations table", () => {
const sqlite = new Database(":memory:");
const sqlite = new DatabaseSync(":memory:");
const dir = mkdtempSync(join(tmpdir(), "nerve-journal2-"));
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
@@ -4,7 +4,7 @@ import type { Signal } from "@uncaged/nerve-core";
import { createSignalBus } from "../signal-bus.js";
function makeSignal(senseId: string, payload: unknown = 1): Signal {
return { id: 1, senseId, payload, ts: Date.now() };
return { id: 1, senseId, payload, timestamp: Date.now() };
}
describe("createSignalBus", () => {
@@ -0,0 +1,235 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockChildren: MockChild[] = [];
type MockChild = EventEmitter & {
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
pid: number;
connected: boolean;
};
function makeMockChild(pid = 1): MockChild {
const child = new EventEmitter() as MockChild;
child.connected = true;
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "shutdown"
) {
child.connected = false;
setImmediate(() => child.emit("exit", 0, null));
}
});
child.kill = vi.fn((_signal?: string) => {
child.connected = false;
child.emit("exit", null, _signal ?? "SIGKILL");
});
child.pid = pid;
return child;
}
vi.mock("node:child_process", () => ({
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
const child = makeMockChild(mockChildren.length + 1);
mockChildren.push(child);
return child;
}),
}));
const { createSenseWorkerPool } = await import("../worker-pool.js");
async function flushSetImmediate(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
async function startWorkerWithReady(
pool: ReturnType<typeof createSenseWorkerPool>,
group: string,
): Promise<void> {
const pr = pool.startWorker(group);
const child = mockChildren[mockChildren.length - 1];
child.emit("message", { type: "ready" });
await pr;
}
describe("createSenseWorkerPool", () => {
beforeEach(() => {
mockChildren.length = 0;
});
afterEach(() => {
vi.useRealTimers();
});
it("forks one child per startWorker and routes IPC to onWorkerMessage", async () => {
const onWorkerMessage = vi.fn();
const pool = createSenseWorkerPool({
nerveRoot: "/tmp/n",
workerScript: "/fake/sense-worker.js",
onWorkerMessage,
sensesForGroup: () => [],
onWorkerCrashed: vi.fn(),
onBeforeGroupRestart: vi.fn(),
isStopped: () => false,
});
await startWorkerWithReady(pool, "g1");
expect(mockChildren).toHaveLength(1);
const child = mockChildren[0];
child.emit("message", { type: "signal", sense: "s", payload: 1 });
expect(onWorkerMessage).toHaveBeenCalledWith({ type: "signal", sense: "s", payload: 1 });
});
it("sendCompute delivers to the worker for that group", async () => {
const pool = createSenseWorkerPool({
nerveRoot: "/tmp/n",
workerScript: "/fake/sense-worker.js",
onWorkerMessage: vi.fn(),
sensesForGroup: () => [],
onWorkerCrashed: vi.fn(),
onBeforeGroupRestart: vi.fn(),
isStopped: () => false,
});
await startWorkerWithReady(pool, "sys");
const child = mockChildren[0];
pool.sendCompute("sys", "cpu");
expect(child.send).toHaveBeenCalledWith(
expect.objectContaining({ type: "compute", sense: "cpu" }),
);
});
it("hasWorkerForGroup and getWorkerPid reflect running workers", async () => {
const pool = createSenseWorkerPool({
nerveRoot: "/tmp/n",
workerScript: "/fake/sense-worker.js",
onWorkerMessage: vi.fn(),
sensesForGroup: () => [],
onWorkerCrashed: vi.fn(),
onBeforeGroupRestart: vi.fn(),
isStopped: () => false,
});
expect(pool.hasWorkerForGroup("a")).toBe(false);
expect(pool.getWorkerPid("a")).toBeNull();
await startWorkerWithReady(pool, "a");
expect(pool.hasWorkerForGroup("a")).toBe(true);
expect(pool.getWorkerPid("a")).toBe(1);
expect(pool.activeGroupCount()).toBe(1);
});
it("evictGroup sends shutdown and removes the entry without waiting", async () => {
const pool = createSenseWorkerPool({
nerveRoot: "/tmp/n",
workerScript: "/fake/sense-worker.js",
onWorkerMessage: vi.fn(),
sensesForGroup: () => [],
onWorkerCrashed: vi.fn(),
onBeforeGroupRestart: vi.fn(),
isStopped: () => false,
});
await startWorkerWithReady(pool, "x");
expect(pool.activeGroupCount()).toBe(1);
pool.evictGroup("x");
expect(pool.hasWorkerForGroup("x")).toBe(false);
expect(mockChildren[0].send).toHaveBeenCalledWith(
expect.objectContaining({ type: "shutdown" }),
);
});
it("restartGroup invokes onBeforeGroupRestart then respawns", async () => {
const onBeforeGroupRestart = vi.fn();
const pool = createSenseWorkerPool({
nerveRoot: "/tmp/n",
workerScript: "/fake/sense-worker.js",
onWorkerMessage: vi.fn(),
sensesForGroup: () => ["s1"],
onWorkerCrashed: vi.fn(),
onBeforeGroupRestart,
isStopped: () => false,
});
await startWorkerWithReady(pool, "g");
expect(mockChildren).toHaveLength(1);
const p = pool.restartGroup("g");
expect(onBeforeGroupRestart).toHaveBeenCalledWith("g");
expect(mockChildren[0].send).toHaveBeenCalledWith(
expect.objectContaining({ type: "shutdown" }),
);
await flushSetImmediate();
expect(mockChildren).toHaveLength(2);
mockChildren[1].emit("message", { type: "ready" });
await p;
expect(pool.hasWorkerForGroup("g")).toBe(true);
});
it("onWorkerCrashed runs and schedules respawn after non-zero exit", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const onWorkerCrashed = vi.fn();
const pool = createSenseWorkerPool({
nerveRoot: "/tmp/n",
workerScript: "/fake/sense-worker.js",
onWorkerMessage: vi.fn(),
sensesForGroup: (g) => (g === "g" ? ["a", "b"] : []),
onWorkerCrashed,
onBeforeGroupRestart: vi.fn(),
isStopped: () => false,
});
await startWorkerWithReady(pool, "g");
expect(mockChildren).toHaveLength(1);
mockChildren[0].emit("exit", 1, null);
expect(onWorkerCrashed).toHaveBeenCalledWith("g");
await vi.advanceTimersByTimeAsync(1000);
expect(mockChildren).toHaveLength(2);
});
it("shutdownAll sends shutdown to every worker", async () => {
const pool = createSenseWorkerPool({
nerveRoot: "/tmp/n",
workerScript: "/fake/sense-worker.js",
onWorkerMessage: vi.fn(),
sensesForGroup: () => [],
onWorkerCrashed: vi.fn(),
onBeforeGroupRestart: vi.fn(),
isStopped: () => false,
});
await startWorkerWithReady(pool, "a");
await startWorkerWithReady(pool, "b");
await pool.shutdownAll();
expect(mockChildren[0].send).toHaveBeenCalledWith(
expect.objectContaining({ type: "shutdown" }),
);
expect(mockChildren[1].send).toHaveBeenCalledWith(
expect.objectContaining({ type: "shutdown" }),
);
});
it("does not respawn after crash when isStopped is true", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const pool = createSenseWorkerPool({
nerveRoot: "/tmp/n",
workerScript: "/fake/sense-worker.js",
onWorkerMessage: vi.fn(),
sensesForGroup: () => [],
onWorkerCrashed: vi.fn(),
onBeforeGroupRestart: vi.fn(),
isStopped: () => true,
});
await startWorkerWithReady(pool, "g");
const n = mockChildren.length;
mockChildren[0].emit("exit", 1, null);
await vi.advanceTimersByTimeAsync(1000);
expect(mockChildren.length).toBe(n);
});
});
@@ -0,0 +1,370 @@
/**
* Tests for WorkflowManager — concurrency, overflow, queue, and lifecycle.
*/
import { EventEmitter } from "node:events";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock child_process.fork before importing workflow-manager
// ---------------------------------------------------------------------------
type MockChild = EventEmitter & {
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
connected: boolean;
exitCode: number | null;
pid: number;
};
const mockChildren: MockChild[] = [];
function makeMockChild(pid = 1): MockChild {
const child = new EventEmitter() as MockChild;
child.connected = true;
child.exitCode = null;
child.pid = pid;
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "shutdown"
) {
setImmediate(() => {
child.exitCode = 0;
child.connected = false;
child.emit("exit", 0, null);
});
}
});
child.kill = vi.fn((_signal?: string) => {
child.exitCode = 1;
child.connected = false;
child.emit("exit", null, _signal ?? "SIGKILL");
});
return child;
}
vi.mock("node:child_process", () => ({
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
const child = makeMockChild(mockChildren.length + 1);
mockChildren.push(child);
return child;
}),
}));
// Import after mock is set up
const { createWorkflowManager } = await import("../workflow-manager.js");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeLogStore() {
return {
append: vi.fn(),
query: vi.fn(() => []),
getMeta: vi.fn(() => null),
setMeta: vi.fn(),
upsertWorkflowRun: vi.fn(),
appendWithWorkflowUpdate: vi.fn(),
getWorkflowRun: vi.fn(() => null),
getActiveWorkflowRuns: vi.fn(() => []),
getTriggerPayload: vi.fn(() => null),
getThreadEvents: vi.fn(() => []),
getThreadMessages: vi.fn(() => []),
getThreadRoundCount: vi.fn(() => 0),
getThreadRounds: vi.fn(() => []),
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
close: vi.fn(),
getAllWorkflowRuns: vi.fn(() => []),
};
}
function makeConfig(overrides: Partial<NerveConfig["workflows"]> = {}): NerveConfig {
return {
maxRounds: 10,
senses: {},
reflexes: [],
workflows: overrides as NerveConfig["workflows"],
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("WorkflowManager", () => {
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe("startWorkflow under concurrency limit dispatches thread", () => {
it("forks a worker and sends start-thread when active < concurrency", () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-workflow": { concurrency: 2, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
expect(mockChildren[0].send).toHaveBeenCalledWith(
expect.objectContaining({ type: "start-thread", workflow: "my-workflow" }),
);
expect(mgr.activeCount("my-workflow")).toBe(1);
});
it("reuses the same worker for a second thread under the limit", () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-workflow": { concurrency: 3, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10, dryRun: false });
// Only one forked child — worker is reused
expect(mockChildren).toHaveLength(1);
expect(mockChildren[0].send).toHaveBeenCalledTimes(2);
expect(mgr.activeCount("my-workflow")).toBe(2);
});
it("logs a 'started' event for each dispatched thread", () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-workflow": { concurrency: 2, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
expect.objectContaining({ source: "workflow", type: "started" }),
expect.objectContaining({ workflow: "my-workflow", status: "started" }),
);
});
});
describe("startWorkflow at limit with drop overflow drops the request", () => {
it("does NOT send start-thread when at concurrency limit with overflow=drop", () => {
const logStore = makeLogStore();
const config = makeConfig({
"drop-wf": { concurrency: 1, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("drop-wf", { prompt: "first", maxRounds: 10, dryRun: false });
// now at limit — second call should be dropped
mgr.startWorkflow("drop-wf", { prompt: "second", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("drop-wf")).toBe(1);
expect(mgr.queueLength("drop-wf")).toBe(0);
// Only one start-thread sent
expect(mockChildren[0].send).toHaveBeenCalledTimes(1);
});
it("logs a 'dropped' event when overflow=drop", () => {
const logStore = makeLogStore();
const config = makeConfig({
"drop-wf": { concurrency: 1, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const droppedCall = logStore.upsertWorkflowRun.mock.calls.find(
([entry]) => entry.type === "dropped",
);
expect(droppedCall).toBeDefined();
});
});
describe("startWorkflow at limit with queue overflow queues the request", () => {
it("queues the thread when at concurrency limit with overflow=queue", () => {
const logStore = makeLogStore();
const config = makeConfig({
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(1);
});
it("logs a 'queued' event for the waiting thread", () => {
const logStore = makeLogStore();
const config = makeConfig({
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const queuedCall = logStore.upsertWorkflowRun.mock.calls.find(
([entry]) => entry.type === "queued",
);
expect(queuedCall).toBeDefined();
expect(queuedCall?.[1]).toMatchObject({ workflow: "queue-wf", status: "queued" });
});
});
describe("queue overflow with maxQueue drops oldest when full", () => {
it("drops oldest queued item when queue reaches maxQueue", () => {
const logStore = makeLogStore();
const config = makeConfig({
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 2 },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// Fill the concurrency slot
mgr.startWorkflow("queue-wf", { prompt: "test 0", maxRounds: 10, dryRun: false });
// Fill the queue to maxQueue
mgr.startWorkflow("queue-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
// This one should push out the oldest
mgr.startWorkflow("queue-wf", { prompt: "test 3", maxRounds: 10, dryRun: false });
// Queue should still be at maxQueue (2)
expect(mgr.queueLength("queue-wf")).toBe(2);
// There should be a dropped event (for the oldest evicted item)
const droppedCalls = logStore.upsertWorkflowRun.mock.calls.filter(
([entry]) => entry.type === "dropped",
);
expect(droppedCalls).toHaveLength(1);
});
});
describe("completing a thread dequeues the next one", () => {
it("dispatches the next queued thread when the active thread sends completed", () => {
const logStore = makeLogStore();
const config = makeConfig({
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(1);
// Simulate the worker sending a "thread-event: completed"
const child = mockChildren[0];
const startCalls = (child.send as ReturnType<typeof vi.fn>).mock.calls;
const firstRunId = (startCalls[0][0] as { runId: string }).runId;
child.emit("message", {
type: "thread-event",
runId: firstRunId,
eventType: "completed",
payload: null,
});
// The queued thread should now be active
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(0);
// A second start-thread message should have been sent
expect(child.send).toHaveBeenCalledTimes(2);
expect(child.send).toHaveBeenLastCalledWith(
expect.objectContaining({ type: "start-thread", workflow: "queue-wf" }),
);
});
it("dispatches next queued thread when active thread sends failed", () => {
const logStore = makeLogStore();
const config = makeConfig({
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
const child = mockChildren[0];
const firstRunId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0].runId as string;
child.emit("message", {
type: "thread-event",
runId: firstRunId,
eventType: "failed",
payload: { error: "boom" },
});
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(0);
});
});
describe("stop() shuts down all workers", () => {
it("sends shutdown to every worker and resolves when they exit", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"wf-a": { concurrency: 2, overflow: "drop" },
"wf-b": { concurrency: 1, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10, dryRun: false });
// Two distinct workers should have been forked
expect(mockChildren).toHaveLength(2);
const stopPromise = mgr.stop();
// Let setImmediate callbacks run (the mock exits on shutdown)
await vi.runAllTimersAsync();
await stopPromise;
for (const child of mockChildren) {
expect(child.send).toHaveBeenCalledWith(expect.objectContaining({ type: "shutdown" }));
}
});
it("ignores startWorkflow calls after stop()", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"wf-a": { concurrency: 2, overflow: "drop" },
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
await stopPromise;
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
// No worker should have been spawned
expect(mockChildren).toHaveLength(0);
});
});
describe("unknown workflow name", () => {
it("does nothing for an unknown workflow name", () => {
const logStore = makeLogStore();
const config = makeConfig({});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("no-such-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(0);
expect(logStore.upsertWorkflowRun).not.toHaveBeenCalled();
});
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* Daemon IPC server — listens on a Unix domain socket so that the CLI
* can send commands (e.g. trigger-workflow, trigger-sense) to the running daemon process.
*
* Protocol: newline-delimited JSON — request/response types and
* `parseDaemonIpcRequest` live in `@uncaged/nerve-core`.
*/
import { rmSync } from "node:fs";
import { type Server, type Socket, createServer } from "node:net";
import type { DaemonIpcResponse, SenseInfo } from "@uncaged/nerve-core";
import { parseDaemonIpcRequest } from "@uncaged/nerve-core";
import type { WorkflowManager } from "./workflow-manager.js";
export type { SenseInfo };
export type DaemonIpcServer = {
close: () => Promise<void>;
};
export type DaemonIpcServerOptions = {
/** Called when a trigger-sense request arrives. Should throw if the sense is unknown. */
triggerSense: (senseName: string) => void;
/** Called when a list-senses request arrives. Returns sense info for all registered senses. */
listSenses: () => SenseInfo[];
};
export function createDaemonIpcServer(
socketPath: string,
workflowManager: WorkflowManager,
opts: DaemonIpcServerOptions,
): DaemonIpcServer {
// Remove stale socket file if it exists
try {
rmSync(socketPath);
} catch {
// file did not exist — that is fine
}
function handleLine(socket: Socket, line: string): void {
const trimmed = line.trim();
if (trimmed.length === 0) return;
const req = parseDaemonIpcRequest(trimmed);
if (req === null) {
const resp: DaemonIpcResponse = { ok: false, error: "Invalid request" };
socket.write(`${JSON.stringify(resp)}\n`);
return;
}
try {
if (req.type === "trigger-workflow") {
workflowManager.startWorkflow(req.workflow, {
prompt: req.prompt,
maxRounds: req.maxRounds,
dryRun: req.dryRun,
});
const resp: DaemonIpcResponse = { ok: true };
socket.write(`${JSON.stringify(resp)}\n`);
} else if (req.type === "trigger-sense") {
opts.triggerSense(req.sense);
const resp: DaemonIpcResponse = { ok: true };
socket.write(`${JSON.stringify(resp)}\n`);
} else if (req.type === "list-senses") {
const senses = opts.listSenses();
const resp: DaemonIpcResponse = { ok: true, senses };
socket.write(`${JSON.stringify(resp)}\n`);
} else if (req.type === "kill-workflow") {
const found = workflowManager.killThread(req.runId);
const resp: DaemonIpcResponse = found
? { ok: true }
: { ok: false, error: `Run not found or already finished: ${req.runId}` };
socket.write(`${JSON.stringify(resp)}\n`);
} else {
const _exhaustive: never = req;
void _exhaustive;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const resp: DaemonIpcResponse = { ok: false, error: msg };
socket.write(`${JSON.stringify(resp)}\n`);
}
}
const server: Server = createServer((socket) => {
let buf = "";
socket.on("data", (chunk: Buffer) => {
buf += chunk.toString("utf8");
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
handleLine(socket, line);
}
});
socket.on("error", () => {
// client disconnected mid-message — ignore
});
});
server.listen(socketPath, () => {
process.stderr.write(`[daemon-ipc] listening on ${socketPath}\n`);
});
server.on("error", (err) => {
process.stderr.write(`[daemon-ipc] server error: ${err.message}\n`);
});
async function close(): Promise<void> {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
try {
rmSync(socketPath);
} catch {
// already removed
}
}
return { close };
}
+130
View File
@@ -0,0 +1,130 @@
/**
* File Watcher — watches nerveRoot for file changes and signals the kernel.
*
* Uses Node.js fs.watch (no external dependencies).
*
* Watched events:
* - .ts file under senses/ modified → callback with { kind: "sense", senseName, filePath }
* - nerve.yaml modified → callback with { kind: "config", filePath }
*
* Debounces rapid changes (e.g. editor save flicker) with a configurable delay.
*
* Linux compatibility note: fs.watch with { recursive: true } relies on inotify, which may
* not reliably report the filename for events in deeply nested subdirectories. On Linux,
* the `filename` argument passed to the callback can be null for some inotify events, and
* recursive watching is only available in Node.js ≥22 on Linux. If null-filename events
* become a problem, consider using a dedicated watcher library (e.g. chokidar).
*/
import { watch } from "node:fs";
import type { FSWatcher } from "node:fs";
import { join, relative, sep } from "node:path";
export type SenseFileChange = {
kind: "sense";
senseName: string;
filePath: string;
};
export type ConfigFileChange = {
kind: "config";
filePath: string;
};
export type WorkflowFileChange = {
kind: "workflow";
workflowName: string;
filePath: string;
};
export type FileChange = SenseFileChange | ConfigFileChange | WorkflowFileChange;
export type FileChangeHandler = (change: FileChange) => void;
export type FileWatcher = {
close: () => void;
};
const DEFAULT_DEBOUNCE_MS = 300;
export function createFileWatcher(
nerveRoot: string,
handler: FileChangeHandler,
debounceMs: number = DEFAULT_DEBOUNCE_MS,
): FileWatcher {
const watchers: FSWatcher[] = [];
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
function debounced(key: string, fn: () => void): void {
const existing = debounceTimers.get(key);
if (existing !== undefined) clearTimeout(existing);
debounceTimers.set(
key,
setTimeout(() => {
debounceTimers.delete(key);
fn();
}, debounceMs),
);
}
function handleSenseChange(normalized: string, filename: string): void {
if (!(normalized.startsWith("senses/") && normalized.endsWith(".ts"))) return;
const rel = relative("senses", normalized);
const senseName = rel.split("/")[0];
if (senseName) {
debounced(`sense:${senseName}`, () => {
handler({ kind: "sense", senseName, filePath: join(nerveRoot, filename) });
});
}
}
function handleWorkflowChange(normalized: string, filename: string): void {
if (!(normalized.startsWith("workflows/") && normalized.endsWith(".ts"))) return;
const rel = relative("workflows", normalized);
const workflowName = rel.split("/")[0];
if (workflowName) {
debounced(`workflow:${workflowName}`, () => {
handler({ kind: "workflow", workflowName, filePath: join(nerveRoot, filename) });
});
}
}
function handleFsEvent(_eventType: string, filename: string | null): void {
if (filename === null) return;
const normalized = filename.split(sep).join("/");
if (normalized === "nerve.yaml") {
debounced("config", () => {
handler({ kind: "config", filePath: join(nerveRoot, "nerve.yaml") });
});
return;
}
handleSenseChange(normalized, filename);
handleWorkflowChange(normalized, filename);
}
try {
const w = watch(nerveRoot, { recursive: true }, (eventType, filename) => {
handleFsEvent(eventType, filename);
});
watchers.push(w);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[file-watcher] Failed to watch "${nerveRoot}": ${msg}\n`);
}
function close(): void {
for (const timer of debounceTimers.values()) {
clearTimeout(timer);
}
debounceTimers.clear();
for (const w of watchers) {
w.close();
}
watchers.length = 0;
}
return { close };
}
+36 -1
View File
@@ -1,11 +1,18 @@
export type {
ComputeMessage,
ShutdownMessage,
HealthRequestMessage,
HealthResponseMessage,
ParentToWorkerMessage,
SignalMessage,
ErrorMessage,
ReadyMessage,
WorkerToParentMessage,
StartThreadMessage,
ResumeThreadMessage,
ThreadEventMessage,
WorkflowErrorMessage,
ThreadWorkflowMessageMessage,
} from "./ipc.js";
export type { SignalBus, SignalHandler, Unsubscribe } from "./signal-bus.js";
@@ -21,4 +28,32 @@ export {
} from "./sense-runtime.js";
export { createKernel } from "./kernel.js";
export type { Kernel, KernelOptions } from "./kernel.js";
export type { Kernel, KernelOptions, KernelHealth } from "./kernel.js";
export type { SenseInfo } from "@uncaged/nerve-core";
export { createFileWatcher } from "./file-watcher.js";
export type { FileWatcher, FileChange, FileChangeHandler } from "./file-watcher.js";
export {
createBlobStore,
createLogStore,
LOG_ARCHIVE_META_KEY,
normalizeBlobHash,
} from "@uncaged/nerve-store";
export type {
ArchiveLogsDayResult,
ArchiveLogsOptions,
ArchiveLogsResult,
BlobStore,
GetThreadRoundsParams,
LogEntry,
LogQuery,
LogStore,
ThreadRoundRow,
WorkflowRun,
WorkflowRunStatus,
} from "@uncaged/nerve-store";
export { createWorkflowManager } from "./workflow-manager.js";
export type { WorkflowManager } from "./workflow-manager.js";
+325 -32
View File
@@ -4,7 +4,7 @@
*/
import type { Result } from "@uncaged/nerve-core";
import { err, ok } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
/** Parent → Worker: trigger one compute cycle for a sense */
export type ComputeMessage = {
@@ -17,8 +17,53 @@ export type ShutdownMessage = {
type: "shutdown";
};
/** Parent → Worker: request health info from worker */
export type HealthRequestMessage = {
type: "health-request";
};
// ---------------------------------------------------------------------------
// Workflow IPC messages (RFC-002 §5.2)
// ---------------------------------------------------------------------------
/** Parent → Workflow Worker: start a new thread */
export type StartThreadMessage = {
type: "start-thread";
runId: string;
workflow: string;
prompt: string;
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
maxRounds: number;
/** When true, roles may skip side effects (thread-level hint on the start frame). */
dryRun: boolean;
};
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
export type ResumeThreadMessage = {
type: "resume-thread";
runId: string;
/** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
/** Safety-valve: max moderator rounds for this thread. */
maxRounds: number;
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
dryRun: boolean;
};
/** Parent → Workflow Worker: kill a specific running thread */
export type KillThreadMessage = {
type: "kill-thread";
runId: string;
};
/** Union of all messages the parent sends to a worker */
export type ParentToWorkerMessage = ComputeMessage | ShutdownMessage;
export type ParentToWorkerMessage =
| ComputeMessage
| ShutdownMessage
| HealthRequestMessage
| StartThreadMessage
| ResumeThreadMessage
| KillThreadMessage;
/** Worker → Parent: compute produced a signal */
export type SignalMessage = {
@@ -39,55 +84,303 @@ export type ReadyMessage = {
type: "ready";
};
/** Worker → Parent: health info response */
export type HealthResponseMessage = {
type: "health-response";
senses: string[];
inFlightCount: number;
};
// ---------------------------------------------------------------------------
// Workflow Worker → Parent messages (RFC-002 §5.2)
// ---------------------------------------------------------------------------
/** Valid lifecycle event types for a workflow thread. */
export type ThreadEventType =
| "queued"
| "started"
| "step_complete"
| "completed"
| "failed"
| "killed";
/**
* Workflow Worker → Parent: a thread lifecycle event.
*/
export type ThreadEventMessage = {
type: "thread-event";
runId: string;
eventType: ThreadEventType;
payload: unknown;
};
/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */
export type WorkflowErrorMessage = {
type: "workflow-error";
runId: string;
error: string;
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
exitCode: number;
};
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
export type ThreadWorkflowMessageMessage = {
type: "thread-workflow-message";
runId: string;
/** The WorkflowMessage produced by the role — persisted for crash recovery. */
message: { role: string; content: string; meta: unknown; timestamp: number };
};
/** Union of all messages a worker sends to the parent */
export type WorkerToParentMessage = SignalMessage | ErrorMessage | ReadyMessage;
export type WorkerToParentMessage =
| SignalMessage
| ErrorMessage
| ReadyMessage
| HealthResponseMessage
| ThreadEventMessage
| WorkflowErrorMessage
| ThreadWorkflowMessageMessage;
const PARENT_MSG_TYPES = new Set([
"compute",
"shutdown",
"health-request",
"start-thread",
"resume-thread",
"kill-thread",
]);
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'start-thread' message missing string 'runId'";
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
return null;
}
function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'";
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
if (typeof obj.maxRounds !== "number")
return "'resume-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
return null;
}
/** Validate and parse an unknown IPC message received from the parent process. */
export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage> {
if (raw === null || typeof raw !== "object") {
if (!isPlainRecord(raw)) {
return err(new Error("IPC message is not an object"));
}
const obj = raw as Record<string, unknown>;
const obj = raw;
if (typeof obj.type !== "string") {
return err(new Error("IPC message missing string 'type' field"));
}
const type = obj.type;
if (type !== "compute" && type !== "shutdown") {
return err(new Error(`Unknown IPC message type: "${type}"`));
if (!PARENT_MSG_TYPES.has(obj.type)) {
return err(new Error(`Unknown IPC message type: "${obj.type}"`));
}
return ok(raw as ParentToWorkerMessage);
if (obj.type === "compute") {
if (typeof obj.sense !== "string") {
return err(new Error("IPC 'compute' message missing string 'sense' field"));
}
return ok({ type: "compute", sense: obj.sense });
}
if (obj.type === "shutdown") {
return ok({ type: "shutdown" });
}
if (obj.type === "health-request") {
return ok({ type: "health-request" });
}
if (obj.type === "start-thread") {
const errMsg = validateStartThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
// Field types are validated above; `Record<string, unknown>` values stay `unknown` to TypeScript.
return ok({
type: "start-thread",
runId: obj.runId,
workflow: obj.workflow,
prompt: obj.prompt,
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as StartThreadMessage);
}
if (obj.type === "resume-thread") {
const errMsg = validateResumeThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
// Elements are validated as plain objects by the kernel; trust the wire shape here.
return ok({
type: "resume-thread",
runId: obj.runId,
messages: obj.messages as ResumeThreadMessage["messages"],
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as ResumeThreadMessage);
}
if (obj.type === "kill-thread") {
if (typeof obj.runId !== "string") {
return err(new Error("'kill-thread' message missing string 'runId'"));
}
return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage);
}
return err(new Error(`Unhandled IPC message type: "${obj.type}"`));
}
function parseSignalMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.sense !== "string") {
return err(new Error("Worker 'signal' message missing string 'sense' field"));
}
if (!("payload" in obj)) {
return err(new Error("Worker 'signal' message missing 'payload' field"));
}
return ok({
type: "signal",
sense: obj.sense,
payload: obj.payload,
});
}
function parseErrorMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.sense !== "string") {
return err(new Error("Worker 'error' message missing string 'sense' field"));
}
if (typeof obj.error !== "string") {
return err(new Error("Worker 'error' message missing string 'error' field"));
}
return ok({
type: "error",
sense: obj.sense,
error: obj.error,
});
}
function parseHealthResponseMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (!Array.isArray(obj.senses)) {
return err(new Error("Worker 'health-response' message missing 'senses' array"));
}
if (typeof obj.inFlightCount !== "number") {
return err(new Error("Worker 'health-response' message missing 'inFlightCount' number"));
}
return ok({
type: "health-response",
// Kernel only sends string[] today; keep accepting any array elements without filtering.
senses: obj.senses as string[],
inFlightCount: obj.inFlightCount,
});
}
function isThreadEventType(value: string): value is ThreadEventType {
switch (value) {
case "queued":
case "started":
case "step_complete":
case "completed":
case "failed":
case "killed":
return true;
default:
return false;
}
}
function parseThreadEventMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
}
if (typeof obj.eventType !== "string" || !isThreadEventType(obj.eventType)) {
return err(
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
);
}
if (!("payload" in obj)) {
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
}
return ok({
type: "thread-event",
runId: obj.runId,
eventType: obj.eventType,
payload: obj.payload,
});
}
function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
}
if (typeof obj.error !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
}
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
return ok({
type: "workflow-error",
runId: obj.runId,
error: obj.error,
exitCode,
});
}
const WORKER_MSG_TYPES = new Set([
"signal",
"error",
"ready",
"health-response",
"thread-event",
"workflow-error",
"thread-workflow-message",
]);
function parseThreadWorkflowMessageMsg(
obj: Record<string, unknown>,
): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
}
if (!isPlainRecord(obj.message)) {
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
}
const msg = obj.message;
if (typeof msg.role !== "string") {
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
}
if (typeof msg.content !== "string") {
return err(
new Error("Worker 'thread-workflow-message' message missing string 'content' field"),
);
}
if (typeof msg.timestamp !== "number") {
return err(
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
);
}
return ok({
type: "thread-workflow-message",
runId: obj.runId,
message: {
role: msg.role,
content: msg.content,
meta: "meta" in msg ? msg.meta : undefined,
timestamp: msg.timestamp,
},
});
}
/** Validate and parse an unknown IPC message received from a worker process. */
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
if (raw === null || typeof raw !== "object") {
if (!isPlainRecord(raw)) {
return err(new Error("Worker IPC message is not an object"));
}
const obj = raw as Record<string, unknown>;
const obj = raw;
if (typeof obj.type !== "string") {
return err(new Error("Worker IPC message missing string 'type' field"));
}
const type = obj.type;
if (type !== "signal" && type !== "error" && type !== "ready") {
return err(new Error(`Unknown worker IPC message type: "${type}"`));
}
if (type === "signal") {
if (typeof obj.sense !== "string") {
return err(new Error("Worker 'signal' message missing string 'sense' field"));
}
if (!("payload" in obj)) {
return err(new Error("Worker 'signal' message missing 'payload' field"));
}
return ok(raw as SignalMessage);
}
if (type === "error") {
if (typeof obj.sense !== "string") {
return err(new Error("Worker 'error' message missing string 'sense' field"));
}
if (typeof obj.error !== "string") {
return err(new Error("Worker 'error' message missing string 'error' field"));
}
return ok(raw as ErrorMessage);
if (!WORKER_MSG_TYPES.has(obj.type)) {
return err(new Error(`Unknown worker IPC message type: "${obj.type}"`));
}
if (obj.type === "signal") return parseSignalMsg(obj);
if (obj.type === "error") return parseErrorMsg(obj);
if (obj.type === "health-response") return parseHealthResponseMsg(obj);
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj);
return ok({ type: "ready" });
}
+92
View File
@@ -0,0 +1,92 @@
/**
* File-watcher callbacks for nerve.yaml / sense / workflow sources (hot reload wiring).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { LogStore } from "@uncaged/nerve-store";
import type { WorkflowManager } from "./workflow-manager.js";
export type KernelFileWatchDeps = {
nerveRoot: string;
getConfig: () => NerveConfig;
logStore: LogStore;
workflowManager: WorkflowManager;
restartGroup: (group: string) => Promise<void>;
reloadConfig: (newConfig: NerveConfig) => void;
};
export type KernelFileWatchHandlers = {
onSenseFileChange: (senseName: string) => void;
onWorkflowFileChange: (workflowName: string) => void;
onConfigFileChange: () => void;
};
export function createKernelFileWatchHandlers(deps: KernelFileWatchDeps): KernelFileWatchHandlers {
function onSenseFileChange(senseName: string): void {
const sc = deps.getConfig().senses[senseName];
if (sc === undefined) return;
process.stderr.write(
`[kernel] sense file changed: "${senseName}", restarting group "${sc.group}"\n`,
);
deps.logStore.append({
source: "system",
type: "sense_reload",
refId: senseName,
payload: null,
timestamp: Date.now(),
});
deps.restartGroup(sc.group).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[kernel] restartGroup error: ${msg}\n`);
});
}
function onWorkflowFileChange(workflowName: string): void {
process.stderr.write(
`[kernel] workflow file changed: "${workflowName}", draining and respawning worker\n`,
);
deps.logStore.append({
source: "system",
type: "workflow_reload",
refId: workflowName,
payload: null,
timestamp: Date.now(),
});
deps.workflowManager.drainAndRespawn(workflowName).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[kernel] drainAndRespawn error for "${workflowName}": ${msg}\n`);
});
}
function onConfigFileChange(): void {
process.stderr.write("[kernel] nerve.yaml changed, reloading config\n");
deps.logStore.append({
source: "system",
type: "config_reload",
refId: null,
payload: null,
timestamp: Date.now(),
});
try {
const raw = readFileSync(join(deps.nerveRoot, "nerve.yaml"), "utf8");
const parseResult = parseNerveConfig(raw);
if (!parseResult.ok) {
process.stderr.write(
`[kernel] config parse error, keeping current config: ${parseResult.error.message}\n`,
);
return;
}
deps.reloadConfig(parseResult.value);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[kernel] failed to read nerve.yaml, keeping current config: ${msg}\n`);
}
}
return { onSenseFileChange, onWorkflowFileChange, onConfigFileChange };
}
@@ -0,0 +1,29 @@
import type { NerveConfig } from "@uncaged/nerve-core";
export function groupForSense(config: NerveConfig, senseName: string): string | null {
const senseConfig = config.senses[senseName];
if (senseConfig === undefined) return null;
return senseConfig.group;
}
export function senseNamesInGroup(config: NerveConfig, group: string): string[] {
return Object.entries(config.senses)
.filter(([, sc]) => sc.group === group)
.map(([name]) => name);
}
export function collectSenseGroups(cfg: NerveConfig): Set<string> {
const result = new Set<string>();
for (const sc of Object.values(cfg.senses)) {
result.add(sc.group);
}
return result;
}
export function senseNamesInGroupAsSet(cfg: NerveConfig, group: string): Set<string> {
const result = new Set<string>();
for (const [name, sc] of Object.entries(cfg.senses)) {
if (sc.group === group) result.add(name);
}
return result;
}
+269 -135
View File
@@ -1,119 +1,107 @@
/**
* Kernel — the main orchestrator that ties sense workers, signal bus, and
* reflex scheduler together.
*
* Responsibilities:
* - Spawn one child process per sense group (via fork)
* - Route SignalMessage from workers → SignalBus
* - Route ErrorMessage from workers → stderr log
* - Drive compute triggers via ReflexScheduler
* - Graceful shutdown: stop scheduler, send shutdown to all workers
* Kernel — ties sense workers, signal bus, reflex scheduler, workflow manager,
* optional file watcher, and daemon IPC.
*/
import { fork } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import type { NerveConfig, Signal } from "@uncaged/nerve-core";
import type { NerveConfig, SenseInfo, Signal } from "@uncaged/nerve-core";
import { routeSenseComputeOutput } from "@uncaged/nerve-core";
import type { ComputeMessage, ShutdownMessage } from "./ipc.js";
import { createLogStore } from "@uncaged/nerve-store";
import type { LogStore } from "@uncaged/nerve-store";
import { createDaemonIpcServer } from "./daemon-ipc.js";
import type { DaemonIpcServer } from "./daemon-ipc.js";
import { createFileWatcher } from "./file-watcher.js";
import type { FileWatcher } from "./file-watcher.js";
import { parseWorkerMessage } from "./ipc.js";
import { createKernelFileWatchHandlers } from "./kernel-file-watch.js";
import {
collectSenseGroups,
groupForSense,
senseNamesInGroup,
senseNamesInGroupAsSet,
} from "./kernel-sense-groups.js";
import { createReflexScheduler } from "./reflex-scheduler.js";
import type { ReflexScheduler } from "./reflex-scheduler.js";
import { createSignalBus } from "./signal-bus.js";
import type { SignalBus } from "./signal-bus.js";
import { createSenseWorkerPool, resolveWorkerScript } from "./worker-pool.js";
import { createWorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "./workflow-manager.js";
export type KernelHealth = {
uptime: number;
activeSenses: number;
activeGroups: number;
pendingComputes: number;
activeWorkflows: number;
memoryUsage: NodeJS.MemoryUsage;
};
export type Kernel = {
stop: () => Promise<void>;
groups: Set<string>;
senseCount: number;
bus: SignalBus;
/** Resolves when all workers have sent their initial "ready" message. */
logStore: LogStore;
workflowManager: WorkflowManager;
ready: Promise<void>;
/** Returns the PID of the worker process for a given group, or null if not found. */
getWorkerPid: (group: string) => number | null;
/** Sends a compute message to the worker responsible for the given sense. */
triggerCompute: (senseName: string) => void;
triggerSense: (senseName: string) => void;
restartGroup: (group: string) => Promise<void>;
reloadConfig: (newConfig: NerveConfig) => void;
getHealth: () => KernelHealth;
};
type WorkerEntry = {
group: string;
process: ChildProcess;
};
function resolveWorkerScript(): string {
const __filename = fileURLToPath(import.meta.url);
const __dir = dirname(__filename);
return join(__dir, "sense-worker.js");
}
function spawnWorker(nerveRoot: string, group: string, workerScript: string): ChildProcess {
return fork(workerScript, ["--group", group, "--root", nerveRoot], {
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
}
function sendCompute(worker: ChildProcess, senseName: string): void {
// worker.connected is false when the IPC channel has been closed (e.g. worker crashed)
if (worker.connected === false) return;
const msg: ComputeMessage = { type: "compute", sense: senseName };
try {
worker.send(msg);
} catch {
// IPC channel closed between connected check and send
}
}
function sendShutdown(worker: ChildProcess): void {
// worker.connected is false when the IPC channel has been closed (e.g. worker crashed)
if (worker.connected === false) return;
const msg: ShutdownMessage = { type: "shutdown" };
try {
worker.send(msg);
} catch {
// IPC channel closed between connected check and send
}
}
function groupForSense(config: NerveConfig, senseName: string): string | null {
const senseConfig = config.senses[senseName];
if (senseConfig === undefined) return null;
return senseConfig.group;
}
export type KernelOptions = {
workerScript: string;
workerScript?: string | null;
enableFileWatcher?: boolean;
logStore?: LogStore;
ipcSocketPath?: string | null;
};
function defaultKernelOptions(): KernelOptions {
return { workerScript: resolveWorkerScript() };
return { workerScript: resolveWorkerScript(), enableFileWatcher: false };
}
export function createKernel(
config: NerveConfig,
initialConfig: NerveConfig,
nerveRoot: string,
options: KernelOptions = defaultKernelOptions(),
): Kernel {
const bus: SignalBus = createSignalBus();
const workerScript = options.workerScript;
const workerScript = options.workerScript ?? resolveWorkerScript();
const startTime = Date.now();
const logStore: LogStore = options.logStore ?? createLogStore(join(nerveRoot, "data", "logs.db"));
logStore.append({
source: "system",
type: "start",
refId: null,
payload: null,
timestamp: startTime,
});
let config = initialConfig;
// Signal ID counter is instance-scoped (fix #2)
let _signalIdCounter = 0;
function nextSignalId(): number {
_signalIdCounter += 1;
return _signalIdCounter;
}
const workflowManager = createWorkflowManager(nerveRoot, config, logStore);
const groups = new Set<string>();
for (const senseConfig of Object.values(config.senses)) {
groups.add(senseConfig.group);
}
const workers = new Map<string, WorkerEntry>();
let stopped = false;
// eslint-disable-next-line prefer-const
let scheduler: ReflexScheduler = null as unknown as ReflexScheduler;
/** Assigned before workers start; `handleWorkerMessage` only runs after this is set. */
let scheduler!: ReflexScheduler;
let readyResolve: (() => void) | undefined;
const ready = new Promise<void>((resolve) => {
@@ -121,10 +109,10 @@ export function createKernel(
});
let pendingReadyCount = groups.size > 0 ? groups.size : 0;
function sensesForGroup(group: string): string[] {
return Object.entries(config.senses)
.filter(([, sc]) => sc.group === group)
.map(([name]) => name);
function clearSchedulerForGroup(group: string): void {
for (const senseName of senseNamesInGroup(config, group)) {
scheduler.onComputeComplete(senseName);
}
}
function handleWorkerMessage(raw: unknown): void {
@@ -145,48 +133,58 @@ export function createKernel(
if (msg.type === "error") {
process.stderr.write(`[kernel] sense "${msg.sense}" error: ${msg.error}\n`);
logStore.append({
source: "system",
type: "error",
refId: msg.sense,
payload: JSON.stringify({ error: msg.error }),
timestamp: Date.now(),
});
scheduler.onComputeComplete(msg.sense);
return;
}
if (msg.type === "signal") {
const signal: Signal = {
id: nextSignalId(),
senseId: msg.sense,
payload: msg.payload,
ts: Date.now(),
};
bus.emit(signal);
const route = routeSenseComputeOutput(msg.payload);
if (route.kind === "launch") {
const { workflowName, maxRounds, prompt } = route.launch;
workflowManager.startWorkflow(workflowName, { prompt, maxRounds, dryRun: false });
logStore.append({
source: "sense",
type: "workflow-launch",
refId: msg.sense,
payload: JSON.stringify(route.launch),
timestamp: Date.now(),
});
} else {
const signal: Signal = {
id: nextSignalId(),
senseId: msg.sense,
payload: route.payload,
timestamp: Date.now(),
};
logStore.append({
source: "sense",
type: "signal",
refId: msg.sense,
payload: JSON.stringify(route.payload),
timestamp: signal.timestamp,
});
bus.emit(signal);
}
scheduler.onComputeComplete(msg.sense);
}
}
function startWorker(group: string): void {
const child = spawnWorker(nerveRoot, group, workerScript);
child.on("message", handleWorkerMessage);
child.on("exit", (code) => {
process.stderr.write(
`[kernel] worker for group "${group}" exited with code ${code ?? "null"}\n`,
);
// Respawn on unexpected exit (fix #4)
if (!stopped && code !== 0) {
process.stderr.write(`[kernel] respawning worker for group "${group}" in 1s\n`);
// Clear any stuck in-flight state for all senses in this group
for (const senseName of sensesForGroup(group)) {
scheduler.onComputeComplete(senseName);
}
setTimeout(() => {
if (!stopped) {
startWorker(group);
}
}, 1000);
}
});
workers.set(group, { group, process: child });
}
const senseWorkerPool = createSenseWorkerPool({
nerveRoot,
workerScript,
onWorkerMessage: handleWorkerMessage,
sensesForGroup: (group) => senseNamesInGroup(config, group),
onWorkerCrashed: clearSchedulerForGroup,
onBeforeGroupRestart: clearSchedulerForGroup,
isStopped: () => stopped,
});
function triggerFn(senseName: string): void {
const group = groupForSense(config, senseName);
@@ -194,54 +192,190 @@ export function createKernel(
process.stderr.write(`[kernel] triggerFn: unknown sense "${senseName}"\n`);
return;
}
const entry = workers.get(group);
if (entry === undefined) {
process.stderr.write(`[kernel] triggerFn: no worker for group "${group}"\n`);
return;
}
sendCompute(entry.process, senseName);
senseWorkerPool.sendCompute(group, senseName);
}
scheduler = createReflexScheduler(config, bus, triggerFn);
function triggerSense(senseName: string): void {
const group = groupForSense(config, senseName);
if (group === null) {
throw new Error(`Unknown sense: "${senseName}"`);
}
if (!senseWorkerPool.hasWorkerForGroup(group)) {
throw new Error(`No worker running for group "${group}" (sense: "${senseName}")`);
}
senseWorkerPool.sendCompute(group, senseName);
}
scheduler = createReflexScheduler(config, bus, triggerFn, {
logStore,
});
if (groups.size === 0) {
readyResolve?.();
}
for (const group of groups) {
startWorker(group);
senseWorkerPool.startWorker(group);
}
// Wait for a worker process to exit, with a timeout + SIGKILL fallback (fix #5)
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
function removeStaleGroups(oldGroups: Set<string>, newGroups: Set<string>): void {
for (const g of oldGroups) {
if (newGroups.has(g)) continue;
senseWorkerPool.evictGroup(g);
groups.delete(g);
}
}
function addNewGroups(oldGroups: Set<string>, newGroups: Set<string>): void {
for (const g of newGroups) {
if (oldGroups.has(g)) continue;
groups.add(g);
if (!stopped) {
senseWorkerPool.startWorker(g);
}
}
}
function reloadConfig(newConfig: NerveConfig): void {
const oldGroups = collectSenseGroups(config);
const oldConfig = config;
const oldWorkflows = config.workflows;
config = newConfig;
scheduler.stop();
scheduler = createReflexScheduler(config, bus, triggerFn, {
logStore,
});
workflowManager.updateConfig(newConfig);
const newWorkflows = newConfig.workflows;
for (const workflowName of Object.keys(oldWorkflows)) {
if (!(workflowName in newWorkflows)) {
process.stderr.write(
`[kernel] workflow "${workflowName}" removed from config, draining worker\n`,
);
workflowManager.drainAndRespawn(workflowName).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`[kernel] drainAndRespawn error for removed workflow "${workflowName}": ${msg}\n`,
);
});
}
}
const newGroups = collectSenseGroups(newConfig);
removeStaleGroups(oldGroups, newGroups);
addNewGroups(oldGroups, newGroups);
for (const g of newGroups) {
if (!oldGroups.has(g)) continue;
const oldSenses = senseNamesInGroupAsSet(oldConfig, g);
const newSenses = senseNamesInGroupAsSet(newConfig, g);
const gained = [...newSenses].some((s) => !oldSenses.has(s));
if (gained) {
senseWorkerPool.restartGroup(g).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[kernel] reloadConfig restartGroup error for "${g}": ${msg}\n`);
});
}
}
}
function getHealth(): KernelHealth {
return {
uptime: Date.now() - startTime,
activeSenses: Object.keys(config.senses).length,
activeGroups: senseWorkerPool.activeGroupCount(),
pendingComputes: 0,
activeWorkflows: workflowManager.totalActiveCount(),
memoryUsage: process.memoryUsage(),
};
}
const fileWatchHandlers = createKernelFileWatchHandlers({
nerveRoot,
getConfig: () => config,
logStore,
workflowManager,
restartGroup: (group) => senseWorkerPool.restartGroup(group),
reloadConfig,
});
let fileWatcher: FileWatcher | null = null;
if (options.enableFileWatcher) {
fileWatcher = createFileWatcher(nerveRoot, (change) => {
if (change.kind === "sense") fileWatchHandlers.onSenseFileChange(change.senseName);
if (change.kind === "config") fileWatchHandlers.onConfigFileChange();
if (change.kind === "workflow") fileWatchHandlers.onWorkflowFileChange(change.workflowName);
});
}
let ipcServer: DaemonIpcServer | null = null;
if (options.ipcSocketPath != null) {
ipcServer = createDaemonIpcServer(options.ipcSocketPath, workflowManager, {
triggerSense,
listSenses(): SenseInfo[] {
return Object.entries(config.senses).map(([name, senseConfig]) => {
const entries = logStore.query({
source: "sense",
type: "signal",
refId: name,
});
const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
return {
name,
group: senseConfig.group,
throttle: senseConfig.throttle,
timeout: senseConfig.timeout,
lastSignalTimestamp: lastEntry !== null ? lastEntry.timestamp : null,
};
});
},
});
}
async function stop(): Promise<void> {
stopped = true;
scheduler.stop();
const exitPromises: Promise<void>[] = [];
for (const entry of workers.values()) {
sendShutdown(entry.process);
exitPromises.push(waitForExit(entry.process, 5000));
if (fileWatcher !== null) {
fileWatcher.close();
fileWatcher = null;
}
await Promise.all(exitPromises);
if (ipcServer !== null) {
await ipcServer.close();
ipcServer = null;
}
scheduler.stop();
await workflowManager.stop();
await senseWorkerPool.shutdownAll();
logStore.append({
source: "system",
type: "stop",
refId: null,
payload: null,
timestamp: Date.now(),
});
logStore.close();
}
function getWorkerPid(group: string): number | null {
return workers.get(group)?.process.pid ?? null;
return senseWorkerPool.getWorkerPid(group);
}
const senseCount = Object.keys(config.senses).length;
return { stop, groups, senseCount, bus, ready, getWorkerPid, triggerCompute: triggerFn };
return {
stop,
groups,
senseCount,
bus,
logStore,
workflowManager,
ready,
getWorkerPid,
triggerCompute: triggerFn,
triggerSense,
restartGroup: (group) => senseWorkerPool.restartGroup(group),
reloadConfig,
getHealth,
};
}
+16 -1
View File
@@ -10,6 +10,7 @@
*/
import type { NerveConfig } from "@uncaged/nerve-core";
import type { LogStore } from "@uncaged/nerve-store";
import type { SignalBus, Unsubscribe } from "./signal-bus.js";
/** Sends a compute message to the worker responsible for the given sense. */
@@ -34,19 +35,26 @@ function makeSenseState(): SenseState {
return { lastComputeAt: 0, inFlight: false, pending: false, deferredTimer: null };
}
export type ReflexSchedulerOptions = {
logStore?: LogStore;
};
/**
* Create and start a reflex scheduler.
*
* @param config Full NerveConfig (reads senses for throttle/timeout, reflexes for schedule).
* @param bus SignalBus to subscribe for event-driven reflexes.
* @param triggerFn Called with the sense name when a compute should be dispatched.
* @param opts Optional: logStore for structured logging.
* @returns ReflexScheduler with stop() and onComputeComplete() methods.
*/
export function createReflexScheduler(
config: NerveConfig,
bus: SignalBus,
triggerFn: TriggerFn,
opts?: ReflexSchedulerOptions,
): ReflexScheduler {
const logStore = opts?.logStore;
const intervals: ReturnType<typeof setInterval>[] = [];
const unsubscribers: Unsubscribe[] = [];
const states = new Map<string, SenseState>();
@@ -65,6 +73,13 @@ export function createReflexScheduler(
state.inFlight = true;
state.pending = false;
state.lastComputeAt = Date.now();
logStore?.append({
source: "reflex",
type: "run_start",
refId: senseName,
payload: null,
timestamp: Date.now(),
});
triggerFn(senseName);
}
@@ -149,7 +164,7 @@ export function createReflexScheduler(
intervals.push(id);
}
if (senseReflex.on !== null && senseReflex.on.length > 0) {
if (senseReflex.on.length > 0) {
const watchedSenses = new Set(senseReflex.on);
const unsub = bus.subscribe((signal) => {
if (watchedSenses.has(signal.senseId)) {
+53 -41
View File
@@ -1,15 +1,17 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { mkdirSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { drizzle } from "drizzle-orm/node-sqlite";
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
import type { Result } from "@uncaged/nerve-core";
import { err, ok } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
import type { BlobStore } from "@uncaged/nerve-store";
/** A Drizzle DB instance (schema-generic) */
export type DrizzleDB = BetterSQLite3Database<Record<string, never>>;
export type DrizzleDB = NodeSQLiteDatabase<Record<string, never>>;
/** Read-only map of peer sense name → their Drizzle DB */
export type PeerMap = Readonly<Record<string, DrizzleDB>>;
@@ -17,11 +19,14 @@ 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`.
* Engine injects `db` (read-write), `peers` (read-only), and `options`
* (`signal`, and `blobs` when running in the sense worker — RFC-001 §8 CAS).
* Returns T when a signal should be emitted, null for silence.
*/
export type ComputeFn<T = unknown> = (
@@ -37,7 +42,7 @@ export type SenseRuntime = {
compute: ComputeFn;
};
function ensureMigrationsTable(sqlite: Database.Database): Result<void> {
function ensureMigrationsTable(sqlite: DatabaseSync): Result<void> {
try {
sqlite.exec(
`CREATE TABLE IF NOT EXISTS _migrations (
@@ -64,11 +69,7 @@ function listMigrationFiles(migrationsDir: string): Result<string[]> {
}
}
function applyMigrationFile(
sqlite: Database.Database,
file: string,
filePath: string,
): Result<void> {
function applyMigrationFile(sqlite: DatabaseSync, file: string, filePath: string): Result<void> {
let sql: string;
try {
sql = readFileSync(filePath, "utf8");
@@ -78,13 +79,18 @@ function applyMigrationFile(
}
const insertJournal = sqlite.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)");
sqlite.exec("BEGIN IMMEDIATE");
try {
sqlite.transaction(() => {
sqlite.exec(sql);
insertJournal.run(file, Date.now());
})();
sqlite.exec(sql);
insertJournal.run(file, Date.now());
sqlite.exec("COMMIT");
return ok(undefined);
} catch (e) {
try {
sqlite.exec("ROLLBACK");
} catch {
// ignore secondary errors during rollback
}
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Migration "${file}" failed: ${msg}`));
}
@@ -92,20 +98,21 @@ function applyMigrationFile(
/**
* Run all *.sql migration files in the given directory against a
* better-sqlite3 Database, in lexicographic order.
* `node:sqlite` DatabaseSync, in lexicographic order.
* Tracks applied migrations in _migrations table to avoid re-running.
*/
export function runMigrations(sqlite: Database.Database, migrationsDir: string): Result<void> {
export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Result<void> {
const tableResult = ensureMigrationsTable(sqlite);
if (!tableResult.ok) return tableResult;
const filesResult = listMigrationFiles(migrationsDir);
if (!filesResult.ok) return filesResult;
const migrationRows = sqlite.prepare("SELECT name FROM _migrations").all();
const applied = new Set<string>(
(sqlite.prepare("SELECT name FROM _migrations").all() as Array<{ name: string }>).map(
(r) => r.name,
),
migrationRows
.filter((r): r is { name: string } => isPlainRecord(r) && typeof r.name === "string")
.map((r) => r.name),
);
for (const file of filesResult.value) {
@@ -124,13 +131,13 @@ export function runMigrations(sqlite: Database.Database, migrationsDir: string):
export function openSenseDb(
dbPath: string,
migrationsDir: string,
): Result<{ sqlite: Database.Database; db: DrizzleDB }> {
let sqlite: Database.Database;
): Result<{ sqlite: DatabaseSync; db: DrizzleDB }> {
let sqlite: DatabaseSync;
try {
sqlite = new Database(dbPath);
// WAL mode for better concurrent read performance
sqlite.pragma("journal_mode = WAL");
mkdirSync(dirname(dbPath), { recursive: true });
sqlite = new DatabaseSync(dbPath);
sqlite.exec("PRAGMA journal_mode=WAL");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to open database "${dbPath}": ${msg}`));
@@ -139,7 +146,8 @@ export function openSenseDb(
const migResult = runMigrations(sqlite, migrationsDir);
if (!migResult.ok) return migResult;
const db = drizzle(sqlite) as DrizzleDB;
// Drizzle infers a schema-specific DB type; senses are schema-agnostic at this layer.
const db = drizzle({ client: sqlite }) as DrizzleDB;
return ok({ sqlite, db });
}
@@ -147,16 +155,17 @@ export function openSenseDb(
* Open a peer sense DB in read-only mode (no migrations).
*/
export function openPeerDb(dbPath: string): Result<DrizzleDB> {
let sqlite: Database.Database;
let sqlite: DatabaseSync;
try {
sqlite = new Database(dbPath, { readonly: true });
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}`));
}
return ok(drizzle(sqlite) as DrizzleDB);
// Same schema-agnostic Drizzle wrapper as openSenseDb.
return ok(drizzle({ client: sqlite }) as DrizzleDB);
}
/**
@@ -167,38 +176,39 @@ export async function loadComputeFn(senseIndexPath: string): Promise<Result<Comp
let mod: unknown;
try {
// Dynamic import required: user-authored sense module, path resolved at runtime
mod = await import(senseIndexPath);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to import sense module "${senseIndexPath}": ${msg}`));
}
if (
mod === null ||
typeof mod !== "object" ||
!("compute" in mod) ||
typeof (mod as Record<string, unknown>).compute !== "function"
) {
if (!isPlainRecord(mod) || !("compute" in mod) || typeof mod.compute !== "function") {
return err(
new Error(`Sense module "${senseIndexPath}" must export a named "compute" function`),
);
}
return ok((mod as { compute: ComputeFn }).compute);
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 `blobStore` is set, it is exposed as `options.blobs` (see RFC-001 §8).
*/
export async function executeCompute(
runtime: SenseRuntime,
peers: PeerMap,
timeoutMs?: number,
blobStore?: BlobStore,
): Promise<Result<unknown | null>> {
const controller = new AbortController();
const options: ComputeOptions = { signal: controller.signal };
const options: ComputeOptions =
blobStore !== undefined
? { signal: controller.signal, blobs: blobStore }
: { signal: controller.signal };
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise =
@@ -220,7 +230,9 @@ export async function executeCompute(
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (controller.signal.aborted) {
return err(new Error(`compute("${runtime.name}") timed out after ${timeoutMs as number}ms`));
return err(
new Error(`compute("${runtime.name}") timed out after ${String(timeoutMs ?? "?")}ms`),
);
}
return err(new Error(`compute("${runtime.name}") threw: ${msg}`));
} finally {
+129 -41
View File
@@ -10,6 +10,7 @@
* 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
*/
@@ -19,10 +20,12 @@ import { join, resolve } from "node:path";
import { parseNerveConfig } 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, loadComputeFn, openPeerDb, openSenseDb } from "./sense-runtime.js";
import type { DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
// ---------------------------------------------------------------------------
// IPC helpers
@@ -79,18 +82,12 @@ async function initSense(
const dbResult = openSenseDb(dbPath, migrationsDir);
if (!dbResult.ok) {
process.stderr.write(
`[sense-worker] Failed to init DB for "${senseName}": ${dbResult.error.message}\n`,
);
process.exit(1);
throw new Error(`Failed to init DB for "${senseName}": ${dbResult.error.message}`);
}
const computeResult = await loadComputeFn(senseIndexPath);
if (!computeResult.ok) {
process.stderr.write(
`[sense-worker] Failed to load compute for "${senseName}": ${computeResult.error.message}\n`,
);
process.exit(1);
throw new Error(`Failed to load compute for "${senseName}": ${computeResult.error.message}`);
}
const { db } = dbResult.value;
@@ -129,13 +126,78 @@ function buildPeers(
return Object.fromEntries(entries);
}
// ---------------------------------------------------------------------------
// Grace period: hard kill after soft timeout
//
// Trade-off: grace period kills the entire worker process (process.exit), which
// terminates all senses in the group — not just the one that timed out. This is
// intentional: a hung compute may hold locks or corrupt shared state. Restarting
// the full worker ensures a clean slate, but other senses in the same group will
// lose any in-flight work until the kernel respawns the process.
// ---------------------------------------------------------------------------
const gracePeriodTimers = new Map<string, ReturnType<typeof setTimeout>>();
function scheduleGracePeriodKill(senseName: string, gracePeriodMs: number): void {
if (gracePeriodTimers.has(senseName)) return;
process.stderr.write(`[sense-worker] grace period for "${senseName}" (${gracePeriodMs}ms)\n`);
const timer = setTimeout(() => {
process.stderr.write(`[sense-worker] grace period expired for "${senseName}", hard kill\n`);
process.exit(1);
}, gracePeriodMs);
gracePeriodTimers.set(senseName, timer);
}
function clearGracePeriodTimer(senseName: string): void {
const timer = gracePeriodTimers.get(senseName);
if (timer === undefined) return;
clearTimeout(timer);
gracePeriodTimers.delete(senseName);
}
// ---------------------------------------------------------------------------
// Compute execution with error isolation
// ---------------------------------------------------------------------------
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, peers, timeoutMs, blobStore);
if (!result.ok) {
sendError(senseName, result.error.message);
if (gracePeriodMs !== null && result.error.message.includes("timed out")) {
scheduleGracePeriodKill(senseName, gracePeriodMs);
}
return;
}
clearGracePeriodTimer(senseName);
if (result.value !== null) {
sendSignal(senseName, result.value);
}
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
sendError(senseName, errMsg);
}
}
// ---------------------------------------------------------------------------
// IPC message dispatch
// ---------------------------------------------------------------------------
function handleMessage(
raw: unknown,
runtimes: Map<string, SenseRuntime>,
peers: PeerMap,
group: string,
timeoutMs: number,
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) {
@@ -149,33 +211,36 @@ function handleMessage(
process.exit(0);
}
if (msg.type === "compute") {
const runtime = runtimes.get(msg.sense);
if (!runtime) {
sendError(msg.sense, `Unknown sense "${msg.sense}" in group "${group}"`);
return;
}
// Serialize computes for the same sense
const previous = inFlight.get(msg.sense) ?? Promise.resolve();
const next = previous.then(async () => {
const result = await executeCompute(runtime, peers, timeoutMs);
if (!result.ok) {
sendError(msg.sense, result.error.message);
return;
}
if (result.value !== null) {
sendSignal(msg.sense, result.value);
}
if (msg.type === "health-request") {
send({
type: "health-response",
senses: [...runtimes.keys()],
inFlightCount: inFlight.size,
});
return;
}
const tracked = next.catch((e: unknown) => {
if (msg.type !== "compute") return;
const runtime = runtimes.get(msg.sense);
if (!runtime) {
sendError(msg.sense, `Unknown sense "${msg.sense}" in group "${group}"`);
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, peers, timeoutMs, gracePeriodMs, blobStore))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendError(msg.sense, errMsg);
});
inFlight.set(msg.sense, tracked);
}
inFlight.set(msg.sense, next);
}
// ---------------------------------------------------------------------------
@@ -198,29 +263,48 @@ 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) {
const { db, runtime } = await initSense(nerveRoot, senseName);
ownDbs.set(senseName, db);
runtimes.set(senseName, runtime);
try {
const { db, runtime } = await initSense(nerveRoot, senseName);
ownDbs.set(senseName, db);
runtimes.set(senseName, runtime);
} catch (e: unknown) {
const eMsg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`[sense-worker] Failed to load sense "${senseName}", skipping: ${eMsg}\n`,
);
failedSenses.push(senseName);
}
}
// 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);
// Read timeout from config (uses first group sense's config, or default)
const firstSenseConfig = config.senses[groupSenses[0]];
const timeoutMs =
typeof (firstSenseConfig as Record<string, unknown>).timeoutMs === "number"
? ((firstSenseConfig as Record<string, unknown>).timeoutMs as number)
: DEFAULT_TIMEOUT_MS;
// 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];
senseConfigs.set(senseName, {
timeout: sc?.timeout ?? null,
gracePeriod: sc?.gracePeriod ?? null,
});
}
const inFlight = new Map<string, Promise<void>>();
const blobStore = createBlobStore(join(nerveRoot, "data", "blobs"));
sendReady();
process.on("message", (raw: unknown) => {
handleMessage(raw, runtimes, peers, group, timeoutMs, inFlight);
handleMessage(raw, runtimes, peers, group, senseConfigs, inFlight, blobStore);
});
}
@@ -253,6 +337,10 @@ if (!parsed) {
process.exit(1);
}
if (typeof process.send === "function") {
ignoreSessionBroadcastSignals();
}
bootstrap(parsed.nerveRoot, parsed.group).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[sense-worker] Unhandled bootstrap error: ${msg}\n`);
@@ -0,0 +1,45 @@
import type { ChildProcess } from "node:child_process";
const STDERR_TAIL_MAX_CHARS = 16_384;
/**
* Forked workers inherit the parent's process group. In foreground `nerve dev`,
* terminal-driven SIGINT/SIGTERM is delivered to the whole group, so workers can exit
* on the default handler before the kernel sends `{ type: "shutdown" }` over IPC.
* Swallow these in worker processes so the parent coordinates shutdown (issue #55).
* Only call when `process.send` is defined (fork IPC); standalone `node …-worker.js` keeps default Ctrl+C behaviour.
*/
export function ignoreSessionBroadcastSignals(): void {
const swallow = (): void => {};
process.on("SIGINT", swallow);
process.on("SIGTERM", swallow);
}
export function teeCapturedStderr(child: ChildProcess, tail: { value: string }): void {
const stream = child.stderr;
if (stream === null || stream === undefined) return;
stream.setEncoding("utf8");
stream.on("data", (chunk: string | Buffer) => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
process.stderr.write(text);
tail.value = (tail.value + text).slice(-STDERR_TAIL_MAX_CHARS);
});
}
export function formatChildExitSummary(code: number | null, signal: NodeJS.Signals | null): string {
const codeStr = code === null || code === undefined ? "null" : String(code);
if (signal) {
return `code=${codeStr} signal=${signal}`;
}
return `code=${codeStr}`;
}
export function formatCapturedStderrTail(tail: string, maxChars = 800): string {
const trimmed = tail.trim();
if (trimmed.length === 0) return "";
const normalized = trimmed.replace(/\r?\n/g, "\\n");
if (normalized.length <= maxChars) {
return ` worker_stderr=${normalized}`;
}
return ` worker_stderr=…${normalized.slice(-maxChars)}`;
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Sense worker pool — forked child processes per sense group (IPC lifecycle).
*/
import { fork } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ComputeMessage, ShutdownMessage } from "./ipc.js";
import { parseWorkerMessage } from "./ipc.js";
import {
formatCapturedStderrTail,
formatChildExitSummary,
teeCapturedStderr,
} from "./worker-fork-support.js";
export function resolveWorkerScript(): string {
const __filename = fileURLToPath(import.meta.url);
const __dir = dirname(__filename);
return join(__dir, "sense-worker.js");
}
type WorkerEntry = {
group: string;
process: ChildProcess;
};
export type SenseWorkerPoolOptions = {
nerveRoot: string;
workerScript: string;
/** Invoked for every IPC message from a worker (including ready / signal / error). */
onWorkerMessage: (raw: unknown) => void;
/** Sense names in a group — used when clearing scheduler state on crash or restart. */
sensesForGroup: (group: string) => string[];
/**
* Called when a worker exits with non-zero code before scheduling a respawn
* (scheduler should release pending computes for senses in that group).
*/
onWorkerCrashed: (group: string) => void;
/**
* Called at the beginning of `restartGroup` before shutdown
* (same scheduler cleanup as crash path).
*/
onBeforeGroupRestart: (group: string) => void;
isStopped: () => boolean;
};
export type SenseWorkerPool = {
startWorker: (group: string) => Promise<void>;
restartGroup: (group: string) => Promise<void>;
/** Send shutdown and drop the entry without waiting (matches reloadConfig stale-group removal). */
evictGroup: (group: string) => void;
shutdownAll: () => Promise<void>;
sendCompute: (group: string, senseName: string) => void;
getWorkerPid: (group: string) => number | null;
hasWorkerForGroup: (group: string) => boolean;
activeGroupCount: () => number;
};
function spawnWorker(
nerveRoot: string,
group: string,
workerScript: string,
stderrTail: { value: string },
): ChildProcess {
const child = fork(workerScript, ["--group", group, "--root", nerveRoot], {
stdio: ["ignore", "inherit", "pipe", "ipc"],
});
teeCapturedStderr(child, stderrTail);
child.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code !== "EPIPE") {
console.error("[worker] error:", err.message);
}
});
return child;
}
function sendComputeToProcess(worker: ChildProcess, senseName: string): void {
if (worker.connected === false) return;
const msg: ComputeMessage = { type: "compute", sense: senseName };
try {
worker.send(msg);
} catch {
// IPC channel closed between connected check and send
}
}
function sendShutdownToProcess(worker: ChildProcess): void {
if (worker.connected === false) return;
const msg: ShutdownMessage = { type: "shutdown" };
try {
worker.send(msg);
} catch {
// IPC channel closed between connected check and send
}
}
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
export function createSenseWorkerPool(options: SenseWorkerPoolOptions): SenseWorkerPool {
const workers = new Map<string, WorkerEntry>();
function startWorker(group: string): Promise<void> {
const stderrTail = { value: "" };
const child = spawnWorker(options.nerveRoot, group, options.workerScript, stderrTail);
let workerReadyResolve: (() => void) | undefined;
const workerReady = new Promise<void>((resolve) => {
workerReadyResolve = resolve;
});
child.on("message", (raw: unknown) => {
const result = parseWorkerMessage(raw);
if (result.ok && result.value.type === "ready") {
workerReadyResolve?.();
}
options.onWorkerMessage(raw);
});
child.on("exit", (code, signal) => {
const summary = formatChildExitSummary(code, signal ?? null);
process.stderr.write(
`[kernel] worker for group "${group}" exited (${summary})${formatCapturedStderrTail(stderrTail.value)}\n`,
);
workerReadyResolve?.();
if (!options.isStopped() && code !== 0) {
process.stderr.write(`[kernel] respawning worker for group "${group}" in 1s\n`);
options.onWorkerCrashed(group);
setTimeout(() => {
if (!options.isStopped()) {
startWorker(group);
}
}, 1000);
}
});
workers.set(group, { group, process: child });
return workerReady;
}
async function restartGroup(group: string): Promise<void> {
const entry = workers.get(group);
if (entry === undefined) return;
options.onBeforeGroupRestart(group);
sendShutdownToProcess(entry.process);
await waitForExit(entry.process, 5000);
if (!options.isStopped()) {
await startWorker(group);
}
}
function evictGroup(group: string): void {
const entry = workers.get(group);
if (entry === undefined) return;
sendShutdownToProcess(entry.process);
workers.delete(group);
}
async function shutdownAll(): Promise<void> {
const exitPromises: Promise<void>[] = [];
for (const entry of workers.values()) {
sendShutdownToProcess(entry.process);
exitPromises.push(waitForExit(entry.process, 5000));
}
await Promise.all(exitPromises);
}
function sendCompute(group: string, senseName: string): void {
const entry = workers.get(group);
if (entry === undefined) return;
sendComputeToProcess(entry.process, senseName);
}
function getWorkerPid(group: string): number | null {
return workers.get(group)?.process.pid ?? null;
}
function hasWorkerForGroup(group: string): boolean {
return workers.has(group);
}
function activeGroupCount(): number {
return workers.size;
}
return {
startWorker,
restartGroup,
evictGroup,
shutdownAll,
sendCompute,
getWorkerPid,
hasWorkerForGroup,
activeGroupCount,
};
}
+706
View File
@@ -0,0 +1,706 @@
/**
* WorkflowManager — manages per-workflow worker processes, concurrency control,
* and thread lifecycle tracking.
*
* RFC-002 §7.1: one worker process per workflow name, reused while alive.
* Concurrency and overflow (drop/queue) are enforced here in the parent process.
*/
import { fork } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { NerveConfig, WorkflowConfig, WorkflowMessage } from "@uncaged/nerve-core";
import { START, isPlainRecord } from "@uncaged/nerve-core";
import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store";
import type {
KillThreadMessage,
ResumeThreadMessage,
ShutdownMessage,
StartThreadMessage,
ThreadEventMessage,
} from "./ipc.js";
import { parseWorkerMessage } from "./ipc.js";
import {
formatCapturedStderrTail,
formatChildExitSummary,
teeCapturedStderr,
} from "./worker-fork-support.js";
export type WorkflowLaunchParams = {
prompt: string;
maxRounds: number;
dryRun: boolean;
};
export type WorkflowManager = {
/** Trigger a new workflow thread (Sense-driven launch or CLI / IPC). */
startWorkflow: (workflowName: string, launch: WorkflowLaunchParams) => void;
/**
* Kill a running or queued workflow thread by runId.
* Returns true if the thread was found, false if not found.
*/
killThread: (runId: string) => boolean;
/** Number of currently active (running) threads for a workflow. */
activeCount: (workflowName: string) => number;
/** Number of pending queued threads waiting to run for a workflow. */
queueLength: (workflowName: string) => number;
/** Total active workflow threads across all workflows. */
totalActiveCount: () => number;
/** Update the config reference (e.g. after hot reload). Active workers are unaffected. */
updateConfig: (newConfig: NerveConfig) => void;
/**
* Drain active threads for a workflow, then respawn its worker process.
* Used for hot reload when the workflow .ts file changes.
* Waits up to `drainTimeoutMs` for threads to complete before force-killing.
*/
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number) => Promise<void>;
/** Gracefully shut down all workflow workers. */
stop: () => Promise<void>;
};
type PendingThread = {
runId: string;
prompt: string;
maxRounds: number;
dryRun: boolean;
};
type WorkflowState = {
active: Set<string>;
queue: PendingThread[];
};
type WorkerEntry = {
workflowName: string;
process: ChildProcess;
stopping: boolean;
/** When set, the worker is draining before a hot-reload respawn. */
draining: boolean;
stderrTail: { value: string };
};
// Crash respawn backoff: track crash timestamps per workflow.
const MAX_CRASHES_IN_WINDOW = 5;
const CRASH_WINDOW_MS = 60_000;
/**
* Worker shutdown timeout — must stay in sync with SHUTDOWN_TIMEOUT_MS in workflow-worker.ts.
* The drain timeout passed to drainAndRespawn must be >= this value so the worker has
* enough time to finish in-flight threads before the parent force-kills it.
*/
const WORKER_SHUTDOWN_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_QUEUE = 100;
function readLaunchFromTriggerPayload(
raw: unknown,
engineDefaultMaxRounds: number,
): { prompt: string; maxRounds: number; dryRun: boolean } {
if (isPlainRecord(raw)) {
const o = raw;
if (typeof o.prompt === "string" && typeof o.maxRounds === "number") {
const dryRun = typeof o.dryRun === "boolean" ? o.dryRun : false;
return { prompt: o.prompt, maxRounds: o.maxRounds, dryRun };
}
}
return { prompt: "", maxRounds: engineDefaultMaxRounds, dryRun: false };
}
function ensureThreadMessagesWithStart(
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>,
fallbackPrompt: string,
fallbackMaxRounds: number,
fallbackDryRun: boolean,
): WorkflowMessage[] {
const mapped: WorkflowMessage[] = messages.map((m) => ({
role: m.role,
content: m.content,
meta: m.meta,
timestamp: m.timestamp,
}));
if (mapped.length > 0 && mapped[0].role === START) {
return mapped;
}
const start: WorkflowMessage = {
role: START,
content: fallbackPrompt,
meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun },
timestamp: Date.now(),
};
return [start, ...mapped];
}
function resolveWorkerScript(): string {
const __filename = fileURLToPath(import.meta.url);
const __dir = dirname(__filename);
return join(__dir, "workflow-worker.js");
}
function spawnWorkflowWorker(
nerveRoot: string,
workflowName: string,
workerScript: string,
stderrTail: { value: string },
): ChildProcess {
const child = fork(workerScript, ["--workflow", workflowName, "--root", nerveRoot], {
stdio: ["ignore", "inherit", "pipe", "ipc"],
});
teeCapturedStderr(child, stderrTail);
// Prevent unhandled EPIPE when writing to a child whose IPC channel closed
child.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code !== "EPIPE") {
console.error("[worker] error:", err.message);
}
});
return child;
}
function sendStartThread(worker: ChildProcess, msg: StartThreadMessage): void {
if (worker.connected === false) return;
try {
worker.send(msg);
} catch {
// IPC channel closed between connected check and send
}
}
function sendShutdown(worker: ChildProcess, entry: WorkerEntry): void {
entry.stopping = true;
if (worker.connected === false) return;
const msg: ShutdownMessage = { type: "shutdown" };
try {
worker.send(msg);
} catch {
// IPC channel closed between connected check and send
}
}
function sendResumeThread(worker: ChildProcess, msg: ResumeThreadMessage): void {
if (worker.connected === false) return;
try {
worker.send(msg);
} catch {
// IPC channel closed between connected check and send
}
}
function sendKillThread(worker: ChildProcess, runId: string): void {
if (worker.connected === false) return;
const msg: KillThreadMessage = { type: "kill-thread", runId };
try {
worker.send(msg);
} catch {
// IPC channel closed between connected check and send
}
}
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
export function createWorkflowManager(
nerveRoot: string,
initialConfig: NerveConfig,
logStore: LogStore,
): WorkflowManager {
const workerScript = resolveWorkerScript();
const states = new Map<string, WorkflowState>();
const workers = new Map<string, WorkerEntry>();
const crashTimestamps = new Map<string, number[]>();
let stopped = false;
let config = initialConfig;
function getOrCreateState(workflowName: string): WorkflowState {
let state = states.get(workflowName);
if (state === undefined) {
state = { active: new Set<string>(), queue: [] };
states.set(workflowName, state);
}
return state;
}
function workflowConfig(workflowName: string): WorkflowConfig | null {
return config.workflows[workflowName] ?? null;
}
function toWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
const map: Record<string, WorkflowRunStatus> = {
started: "started",
queued: "queued",
completed: "completed",
failed: "failed",
crashed: "crashed",
dropped: "dropped",
interrupted: "interrupted",
killed: "killed",
};
return map[eventType] ?? null;
}
function extractExitCode(payload: unknown): number | null {
if (isPlainRecord(payload) && typeof payload.exitCode === "number") {
return payload.exitCode;
}
return null;
}
function logWorkflowEvent(
workflowName: string,
runId: string,
eventType: string,
payload?: unknown,
exitCode: number | null = null,
): void {
const timestamp = Date.now();
const serialised = payload !== undefined ? JSON.stringify(payload) : null;
const status = toWorkflowRunStatus(eventType);
if (status !== null) {
logStore.upsertWorkflowRun(
{
source: "workflow",
type: eventType,
refId: runId,
payload: serialised,
timestamp,
},
{ runId, workflow: workflowName, status, timestamp, exitCode },
);
} else {
logStore.append({
source: "workflow",
type: eventType,
refId: runId,
payload: serialised,
timestamp,
});
}
}
function dispatchThread(
workflowName: string,
runId: string,
prompt: string,
maxRounds: number,
dryRun: boolean,
): void {
const state = getOrCreateState(workflowName);
state.active.add(runId);
const worker = getOrSpawnWorker(workflowName);
const msg: StartThreadMessage = {
type: "start-thread",
runId,
workflow: workflowName,
prompt,
maxRounds,
dryRun,
};
sendStartThread(worker.process, msg);
logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds, dryRun });
}
function dequeueNext(workflowName: string): void {
const state = states.get(workflowName);
if (state === undefined || state.queue.length === 0) return;
const wfConfig = workflowConfig(workflowName);
const concurrency = wfConfig?.concurrency ?? 1;
if (state.active.size < concurrency) {
const next = state.queue.shift();
if (next !== undefined) {
dispatchThread(workflowName, next.runId, next.prompt, next.maxRounds, next.dryRun);
}
}
}
function handleThreadEvent(workflowName: string, msg: ThreadEventMessage): void {
const state = states.get(workflowName);
if (state === undefined) return;
if (msg.eventType === "completed" || msg.eventType === "failed" || msg.eventType === "killed") {
state.active.delete(msg.runId);
dequeueNext(workflowName);
const exitCode = extractExitCode(msg.payload);
logWorkflowEvent(workflowName, msg.runId, msg.eventType, msg.payload, exitCode);
}
}
function recoverQueuedRun(workflowName: string, runId: string, state: WorkflowState): void {
if (state.queue.some((q) => q.runId === runId)) return;
const launch = readLaunchFromTriggerPayload(
logStore.getTriggerPayload(runId),
config.maxRounds,
);
state.queue.push({
runId,
prompt: launch.prompt,
maxRounds: launch.maxRounds,
dryRun: launch.dryRun,
});
process.stderr.write(
`[workflow-manager] crash-recovery: re-queued thread "${runId}" for "${workflowName}"\n`,
);
}
function recoverStartedRun(
workflowName: string,
runId: string,
state: WorkflowState,
worker: WorkerEntry,
): void {
if (state.active.has(runId)) return;
const rawMessages = logStore.getThreadMessages(runId);
const launch = readLaunchFromTriggerPayload(
logStore.getTriggerPayload(runId),
config.maxRounds,
);
const messages = ensureThreadMessagesWithStart(
rawMessages,
launch.prompt,
launch.maxRounds,
launch.dryRun,
);
state.active.add(runId);
const msg: ResumeThreadMessage = {
type: "resume-thread",
runId,
messages,
maxRounds: launch.maxRounds,
dryRun: launch.dryRun,
};
sendResumeThread(worker.process, msg);
process.stderr.write(
`[workflow-manager] crash-recovery: resuming thread "${runId}" for "${workflowName}" (${messages.length} messages)\n`,
);
}
function recoverThreadsForWorker(workflowName: string, worker: WorkerEntry): void {
const activeRuns = logStore.getActiveWorkflowRuns(workflowName);
const state = getOrCreateState(workflowName);
for (const run of activeRuns) {
if (run.status === "queued") {
recoverQueuedRun(workflowName, run.runId, state);
} else if (run.status === "started") {
recoverStartedRun(workflowName, run.runId, state, worker);
}
}
}
function recordCrashAndCheckLimit(workflowName: string): boolean {
const now = Date.now();
const timestamps = (crashTimestamps.get(workflowName) ?? []).filter(
(t) => now - t < CRASH_WINDOW_MS,
);
timestamps.push(now);
crashTimestamps.set(workflowName, timestamps);
return timestamps.length > MAX_CRASHES_IN_WINDOW;
}
function handleWorkerCrash(workflowName: string): void {
const state = states.get(workflowName);
if (state === undefined) return;
const crashedCount = state.active.size;
if (crashedCount > 0) {
process.stderr.write(
`[workflow-manager] worker for "${workflowName}" crashed with ${crashedCount} active thread(s)\n`,
);
for (const runId of state.active) {
logWorkflowEvent(workflowName, runId, "crashed", undefined, 255);
}
}
state.active.clear();
workers.delete(workflowName);
if (stopped || workflowConfig(workflowName) === null) return;
if (recordCrashAndCheckLimit(workflowName)) {
const count = crashTimestamps.get(workflowName)?.length ?? 0;
process.stderr.write(
`[workflow-manager] worker for "${workflowName}" crashed ${count} times in ${CRASH_WINDOW_MS}ms — stopping respawn\n`,
);
return;
}
process.stderr.write(
`[workflow-manager] respawning worker for "${workflowName}" after crash\n`,
);
const newWorker = getOrSpawnWorker(workflowName);
setImmediate(() => {
recoverThreadsForWorker(workflowName, newWorker);
});
}
function handleWorkerMessage(workflowName: string, raw: unknown): void {
const result = parseWorkerMessage(raw);
if (!result.ok) {
process.stderr.write(
`[workflow-manager] invalid message from "${workflowName}" worker: ${result.error.message}\n`,
);
return;
}
const msg = result.value;
if (msg.type === "thread-event") {
handleThreadEvent(workflowName, msg);
return;
}
if (msg.type === "thread-workflow-message") {
logStore.append({
source: "workflow",
type: "thread_workflow_message",
refId: msg.runId,
payload: JSON.stringify(msg.message),
timestamp: Date.now(),
});
return;
}
if (msg.type === "workflow-error") {
process.stderr.write(
`[workflow-manager] workflow-error for runId "${msg.runId}" in "${workflowName}": ${msg.error}\n`,
);
const state = states.get(workflowName);
if (state !== undefined) {
state.active.delete(msg.runId);
dequeueNext(workflowName);
}
logWorkflowEvent(workflowName, msg.runId, "failed", { error: msg.error }, msg.exitCode);
return;
}
if (msg.type === "error") {
process.stderr.write(
`[workflow-manager] error from "${workflowName}" worker: ${msg.error}\n`,
);
}
}
function markActiveRunsInterrupted(workflowName: string): void {
const state = states.get(workflowName);
if (state === undefined) return;
for (const runId of state.active) {
logWorkflowEvent(workflowName, runId, "interrupted");
}
state.active.clear();
}
function handleWorkerExit(
workflowName: string,
code: number | null,
signal: NodeJS.Signals | null,
): void {
const entry = workers.get(workflowName);
if (entry?.draining) {
workers.delete(workflowName);
markActiveRunsInterrupted(workflowName);
if (!stopped && workflowConfig(workflowName) !== null) {
process.stderr.write(
`[workflow-manager] worker for "${workflowName}" drained, respawning\n`,
);
getOrSpawnWorker(workflowName);
}
return;
}
if (entry?.stopping) {
workers.delete(workflowName);
const state = states.get(workflowName);
if (state !== undefined) {
state.active.clear();
}
return;
}
const summary = formatChildExitSummary(code, signal);
const stderrExtra = entry !== undefined ? formatCapturedStderrTail(entry.stderrTail.value) : "";
process.stderr.write(
`[workflow-manager] worker for "${workflowName}" exited (${summary})${stderrExtra}\n`,
);
handleWorkerCrash(workflowName);
}
function getOrSpawnWorker(workflowName: string): WorkerEntry {
const existing = workers.get(workflowName);
if (existing !== undefined && existing.process.exitCode === null) {
return existing;
}
const stderrTail = { value: "" };
const child = spawnWorkflowWorker(nerveRoot, workflowName, workerScript, stderrTail);
child.on("message", (raw: unknown) => {
handleWorkerMessage(workflowName, raw);
});
child.on("exit", (code, signal) => {
handleWorkerExit(workflowName, code, signal ?? null);
});
const entry: WorkerEntry = {
workflowName,
process: child,
stopping: false,
draining: false,
stderrTail,
};
workers.set(workflowName, entry);
return entry;
}
function killThread(runId: string): boolean {
for (const [workflowName, state] of states) {
const queueIdx = state.queue.findIndex((q) => q.runId === runId);
if (queueIdx !== -1) {
state.queue.splice(queueIdx, 1);
logWorkflowEvent(workflowName, runId, "killed", { exitCode: 137 }, 137);
return true;
}
if (state.active.has(runId)) {
const workerEntry = workers.get(workflowName);
if (workerEntry !== undefined) {
sendKillThread(workerEntry.process, runId);
}
return true;
}
}
return false;
}
function startWorkflow(workflowName: string, launch: WorkflowLaunchParams): void {
if (stopped) return;
const wfConfig = workflowConfig(workflowName);
if (wfConfig === null) {
process.stderr.write(
`[workflow-manager] startWorkflow: unknown workflow "${workflowName}"\n`,
);
return;
}
const state = getOrCreateState(workflowName);
const runId = crypto.randomUUID();
const { prompt, maxRounds, dryRun } = launch;
if (state.active.size < wfConfig.concurrency) {
dispatchThread(workflowName, runId, prompt, maxRounds, dryRun);
return;
}
// At concurrency limit — apply overflow policy
if (wfConfig.overflow === "drop") {
logWorkflowEvent(workflowName, runId, "dropped");
process.stderr.write(
`[workflow-manager] dropped thread for "${workflowName}" (overflow=drop)\n`,
);
return;
}
// overflow === "queue"
const maxQueue = (wfConfig as { maxQueue?: number }).maxQueue ?? DEFAULT_MAX_QUEUE;
if (state.queue.length >= maxQueue) {
const oldest = state.queue.shift();
if (oldest !== undefined) {
process.stderr.write(
`[workflow-manager] queue overflow for "${workflowName}", dropping oldest runId "${oldest.runId}"\n`,
);
logWorkflowEvent(workflowName, oldest.runId, "dropped");
}
}
state.queue.push({ runId, prompt, maxRounds, dryRun });
logWorkflowEvent(workflowName, runId, "queued");
process.stderr.write(
`[workflow-manager] queued thread for "${workflowName}" runId "${runId}" (queue length: ${state.queue.length})\n`,
);
}
function activeCount(workflowName: string): number {
return states.get(workflowName)?.active.size ?? 0;
}
function queueLength(workflowName: string): number {
return states.get(workflowName)?.queue.length ?? 0;
}
function totalActiveCount(): number {
let total = 0;
for (const state of states.values()) {
total += state.active.size;
}
return total;
}
function updateConfig(newConfig: NerveConfig): void {
config = newConfig;
}
/**
* Default drain timeout must be at least WORKER_SHUTDOWN_TIMEOUT_MS so the worker
* has enough time to finish in-flight threads before the parent force-kills it.
*/
const DEFAULT_DRAIN_TIMEOUT_MS = Math.max(30_000, WORKER_SHUTDOWN_TIMEOUT_MS + 5_000);
async function drainAndRespawn(
workflowName: string,
drainTimeoutMs: number = DEFAULT_DRAIN_TIMEOUT_MS,
): Promise<void> {
const entry = workers.get(workflowName);
if (entry === undefined) {
// No active worker — nothing to drain
return;
}
entry.draining = true;
// Send shutdown without setting stopping=true (so the exit handler uses the draining branch)
if (entry.process.connected) {
const msg: ShutdownMessage = { type: "shutdown" };
try {
entry.process.send(msg);
} catch {
// IPC closed
}
}
await waitForExit(entry.process, drainTimeoutMs);
// The exit handler (draining branch) will respawn the worker automatically
}
async function stop(): Promise<void> {
stopped = true;
const exitPromises: Promise<void>[] = [];
for (const entry of workers.values()) {
sendShutdown(entry.process, entry);
exitPromises.push(waitForExit(entry.process, 5000));
}
await Promise.all(exitPromises);
workers.clear();
}
return {
startWorkflow,
killThread,
activeCount,
queueLength,
totalActiveCount,
updateConfig,
drainAndRespawn,
stop,
};
}
+463
View File
@@ -0,0 +1,463 @@
/**
* Workflow Worker runtime bootstrap.
*
* Entry point for a forked workflow worker process.
* Receives the workflow name and nerve root via CLI args, dynamically imports
* the user's WorkflowDefinition, then signals ready and enters the IPC event loop.
*
* Layout assumptions (nerve user config at `<nerveRoot>/`):
* workflows/<name>/index.ts (or .js) ← user workflow definition
*/
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import type { RoleMeta, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core";
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
import type {
ThreadEventType,
ThreadWorkflowMessageMessage,
WorkerToParentMessage,
} from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
// ---------------------------------------------------------------------------
// IPC helpers
// ---------------------------------------------------------------------------
function send(msg: WorkerToParentMessage): void {
if (process.send) {
process.send(msg);
}
}
function sendReady(): void {
send({ type: "ready" });
}
function sendThreadEvent(runId: string, eventType: ThreadEventType, payload: unknown): void {
send({ type: "thread-event", runId, eventType, payload });
}
function sendWorkflowError(runId: string, error: string, exitCode = 1): void {
send({ type: "workflow-error", runId, error, exitCode });
}
function sendWorkflowMessage(runId: string, message: WorkflowMessage): void {
const msg: ThreadWorkflowMessageMessage = {
type: "thread-workflow-message",
runId,
message: {
role: message.role,
content: message.content,
meta: message.meta,
timestamp: message.timestamp,
},
};
send(msg);
}
// ---------------------------------------------------------------------------
// Thread loop (signal-driven automaton, issue #80)
// ---------------------------------------------------------------------------
function validateRoleResult(
result: { content: string; meta: Record<string, unknown> },
roleName: string,
runId: string,
): boolean {
if (typeof result.content !== "string") {
sendWorkflowError(runId, `Role "${roleName}" returned non-string content`);
return false;
}
if (result.meta === null || typeof result.meta !== "object" || Array.isArray(result.meta)) {
sendWorkflowError(runId, `Role "${roleName}" returned invalid meta (must be a plain object)`);
return false;
}
return true;
}
function isStartMeta(meta: unknown): meta is StartStep["meta"] {
return (
isPlainRecord(meta) && typeof meta.maxRounds === "number" && typeof meta.dryRun === "boolean"
);
}
function normalizeStartMeta(meta: unknown, maxRoundsFallback: number): StartStep["meta"] {
if (!isPlainRecord(meta)) {
return { maxRounds: maxRoundsFallback, dryRun: false };
}
const maxRounds = typeof meta.maxRounds === "number" ? meta.maxRounds : maxRoundsFallback;
const dryRun = typeof meta.dryRun === "boolean" ? meta.dryRun : false;
return { maxRounds, dryRun };
}
function startStepFromWorkflowMessage(msg: WorkflowMessage, maxRoundsFallback: number): StartStep {
if (msg.role !== START) {
return {
role: START,
content: "",
meta: { maxRounds: maxRoundsFallback, dryRun: false },
timestamp: Date.now(),
};
}
const meta = isStartMeta(msg.meta) ? msg.meta : normalizeStartMeta(msg.meta, maxRoundsFallback);
return {
role: START,
content: msg.content,
meta,
timestamp: msg.timestamp,
};
}
type ThreadMessagesState = {
start: StartStep;
/** Role outputs only; never includes the `__start__` frame. */
messages: WorkflowMessage[];
};
function initThreadMessages(
runId: string,
resumeMessages: WorkflowMessage[],
freshPrompt: string | null,
maxRounds: number,
dryRun: boolean,
): ThreadMessagesState {
if (resumeMessages.length > 0) {
const [first, ...rest] = resumeMessages;
if (first.role === START) {
return {
start: startStepFromWorkflowMessage(first, maxRounds),
messages: [...rest],
};
}
const prompt = freshPrompt ?? "";
return {
start: {
role: START,
content: prompt,
meta: { maxRounds, dryRun },
timestamp: Date.now(),
},
messages: [...resumeMessages],
};
}
const prompt = freshPrompt ?? "";
const start: StartStep = {
role: START,
content: prompt,
meta: { maxRounds, dryRun },
timestamp: Date.now(),
};
sendWorkflowMessage(runId, {
role: start.role,
content: start.content,
meta: start.meta,
timestamp: start.timestamp,
});
return { start, messages: [] };
}
async function executeRole(
def: WorkflowDefinition<RoleMeta>,
nextRole: string,
start: StartStep,
messages: WorkflowMessage[],
runId: string,
): Promise<{ content: string; meta: Record<string, unknown> } | null> {
const role = def.roles[nextRole];
if (!role) {
sendWorkflowError(runId, `Unknown role: ${nextRole}`);
return null;
}
let result: { content: string; meta: Record<string, unknown> };
try {
result = await role(start, messages);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
sendThreadEvent(runId, "failed", { error: errMsg, exitCode: 1 });
return null;
}
if (!validateRoleResult(result, nextRole, runId)) return null;
return result;
}
type KillFlag = { value: boolean };
async function runThread(
def: WorkflowDefinition<RoleMeta>,
runId: string,
maxRounds: number,
killFlag: KillFlag,
resumeMessages: WorkflowMessage[] = [],
freshPrompt: string | null = null,
dryRun = false,
): Promise<void> {
const { start, messages: roleMessages } = initThreadMessages(
runId,
resumeMessages,
freshPrompt,
maxRounds,
dryRun,
);
const steps: Array<{
role: string;
meta: Record<string, unknown>;
content: string;
timestamp: number;
}> = [];
// Rebuild steps from any resumed messages
for (const msg of roleMessages) {
steps.push({
role: msg.role,
meta: msg.meta as Record<string, unknown>,
content: msg.content,
timestamp: msg.timestamp,
});
}
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
return;
}
let nextRole = def.moderator({ start, steps });
if (nextRole === END) {
sendThreadEvent(runId, "completed", { exitCode: 0 });
return;
}
while (steps.length < maxRounds) {
const result = await executeRole(def, nextRole, start, roleMessages, runId);
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
return;
}
if (result === null) return;
const message: WorkflowMessage = {
role: nextRole,
content: result.content,
meta: result.meta,
timestamp: Date.now(),
};
roleMessages.push(message);
sendWorkflowMessage(runId, message);
steps.push({
role: nextRole,
meta: result.meta,
content: result.content,
timestamp: message.timestamp,
});
nextRole = def.moderator({ start, steps });
if (nextRole === END) {
sendThreadEvent(runId, "completed", { exitCode: 0 });
return;
}
}
sendWorkflowError(runId, `Thread exceeded maximum rounds (${maxRounds})`, 2);
}
// ---------------------------------------------------------------------------
// Workflow definition loader
// ---------------------------------------------------------------------------
function isWorkflowDefinitionShape(def: unknown): def is WorkflowDefinition<RoleMeta> {
if (!isPlainRecord(def)) return false;
return (
typeof def.moderator === "function" &&
typeof def.roles === "object" &&
def.roles !== null &&
!Array.isArray(def.roles) &&
typeof def.name === "string"
);
}
async function loadWorkflowDefinition(
nerveRoot: string,
workflowName: string,
): Promise<WorkflowDefinition<RoleMeta>> {
const candidates = [
resolve(join(nerveRoot, "workflows", workflowName, "index.ts")),
resolve(join(nerveRoot, "workflows", workflowName, "index.js")),
];
const indexPath = candidates.find((p) => existsSync(p));
if (!indexPath) {
throw new Error(
`Workflow definition not found for "${workflowName}". Tried:\n${candidates.map((p) => ` ${p}`).join("\n")}`,
);
}
// Dynamic import required: user-authored workflow module, path resolved at runtime
const mod = await import(indexPath);
const def: unknown = mod.default ?? mod;
if (!isWorkflowDefinitionShape(def)) {
throw new Error(
`Workflow "${workflowName}" must export a WorkflowDefinition with "name", "roles", and "moderator".`,
);
}
return def;
}
// ---------------------------------------------------------------------------
// IPC message dispatch
// ---------------------------------------------------------------------------
function handleMessage(
raw: unknown,
def: WorkflowDefinition<RoleMeta>,
inFlight: Map<string, Promise<void>>,
killFlags: Map<string, KillFlag>,
shuttingDown: { value: boolean },
): void {
const parseResult = parseParentMessage(raw);
if (!parseResult.ok) {
process.stderr.write(`[workflow-worker] Invalid IPC message: ${parseResult.error.message}\n`);
return;
}
const msg = parseResult.value;
if (msg.type === "shutdown") {
shuttingDown.value = true;
const timeout = new Promise<void>((r) => setTimeout(r, 10_000));
Promise.race([Promise.all(inFlight.values()), timeout])
.then(() => process.exit(0))
.catch(() => process.exit(1));
return;
}
if (msg.type === "start-thread") {
if (shuttingDown.value) return;
const { runId, prompt, maxRounds, dryRun } = msg;
const killFlag: KillFlag = { value: false };
killFlags.set(runId, killFlag);
const previous = inFlight.get(runId) ?? Promise.resolve();
const next = previous
.then(() => runThread(def, runId, maxRounds, killFlag, [], prompt, dryRun))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendWorkflowError(runId, errMsg);
})
.finally(() => {
inFlight.delete(runId);
killFlags.delete(runId);
});
inFlight.set(runId, next);
return;
}
if (msg.type === "resume-thread") {
if (shuttingDown.value) return;
const { runId, messages, maxRounds, dryRun } = msg;
const killFlag: KillFlag = { value: false };
killFlags.set(runId, killFlag);
const previous = inFlight.get(runId) ?? Promise.resolve();
const next = previous
.then(() => runThread(def, runId, maxRounds, killFlag, messages, null, dryRun))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendWorkflowError(runId, errMsg);
})
.finally(() => {
inFlight.delete(runId);
killFlags.delete(runId);
});
inFlight.set(runId, next);
return;
}
if (msg.type === "kill-thread") {
const flag = killFlags.get(msg.runId);
if (flag !== undefined) {
flag.value = true;
}
return;
}
}
// ---------------------------------------------------------------------------
// Bootstrap
// ---------------------------------------------------------------------------
async function bootstrap(nerveRoot: string, workflowName: string): Promise<void> {
let def: WorkflowDefinition<RoleMeta>;
try {
def = await loadWorkflowDefinition(nerveRoot, workflowName);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[workflow-worker] Failed to load workflow "${workflowName}": ${msg}\n`);
process.exit(1);
}
const inFlight = new Map<string, Promise<void>>();
const killFlags = new Map<string, KillFlag>();
const shuttingDown = { value: false };
sendReady();
process.on("message", (raw: unknown) => {
handleMessage(raw, def, inFlight, killFlags, shuttingDown);
});
}
// ---------------------------------------------------------------------------
// CLI entrypoint
// ---------------------------------------------------------------------------
function parseArgs(): { nerveRoot: string; workflow: string } | null {
const args = process.argv.slice(2);
let workflow: string | null = null;
let nerveRoot: string | null = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--workflow" && i + 1 < args.length) {
workflow = args[i + 1];
i++;
} else if (args[i] === "--root" && i + 1 < args.length) {
nerveRoot = args[i + 1];
i++;
}
}
if (!workflow || !nerveRoot) return null;
return { nerveRoot, workflow };
}
const parsed = parseArgs();
if (!parsed) {
process.stderr.write("Usage: workflow-worker --workflow <name> --root <nerve-root-dir>\n");
process.exit(1);
}
if (typeof process.send === "function") {
ignoreSessionBroadcastSignals();
}
bootstrap(parsed.nerveRoot, parsed.workflow).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[workflow-worker] Unhandled bootstrap error: ${msg}\n`);
process.exit(1);
});
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/sense-worker.ts"],
format: ["esm"],
dts: true,
clean: true,
});
+48
View File
@@ -0,0 +1,48 @@
# @uncaged/nerve-store
Persistent storage for the [nerve](../../README.md) daemon — append-only structured logs, optional JSONL cold archive, and content-addressable blobs.
## LogStore (`createLogStore`, `log-store.ts`)
- **Append-only log table** — rows with `source`, `type`, `refId`, `payload`, `ts` (string payloads for ad hoc fields)
- **SQLite WAL** — `DatabaseSync` from `node:sqlite`
- **Workflow run tracking** — materialized `workflow_runs` table plus helpers to list active runs, upsert status transitions, and read **thread messages** / **role rounds** for CLI and crash recovery
- **Meta key-value** — small `meta` table (e.g. archive watermarks)
Public exports include `LogStore`, `LogEntry`, `LogQuery`, `WorkflowRun`, `WorkflowRunStatus`, `ThreadRoundRow`, `GetThreadRoundsParams`, and archive-related types re-exported from `log-archive`.
## WorkflowRunStatus
Runs progress through a small state machine. Typical paths:
1. **`queued`** → **`started`** when a worker picks up the thread
2. **`started`** → **`completed`** | **`failed`** | **`crashed`** | **`interrupted`** | **`dropped`**
Semantics in the daemon/store layer:
- **`completed` / `failed`** — normal terminal outcomes from the workflow worker
- **`crashed`** — worker exited unexpectedly; manager may respawn and **`resume-thread`** eligible **`started`** runs
- **`interrupted`** — e.g. hot-reload drain killed an in-flight thread after timeout
- **`dropped`** — concurrency **`overflow: drop`** rejected a new run, or **`overflow: queue`** evicted an queued item when the queue was full
## LogArchive (`log-archive.ts`)
- **`archiveLogs`** / helpers — export eligible UTC days of old rows to **`data/archive/logs/YYYY-MM-DD.jsonl`**, delete archived rows from SQLite, optional **`VACUUM`**
- Used by **`nerve store archive`** in `@uncaged/nerve-cli`
## BlobStore (`createBlobStore`, `blob-store.ts`)
- **Content-addressable storage** — `write` returns lowercase **sha256** hex; files live under **`data/blobs/<2-hex>/<62-hex>`**
- **`read` / `exists`** — path must match digest on disk (tamper detection)
## Install
```bash
pnpm add @uncaged/nerve-store
```
Requires Node.js ≥ 22.5 (same as the rest of the stack).
## License
MIT
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/nerve-store",
"version": "0.4.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"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
}
}

Some files were not shown because too many files have changed in this diff Show More