From cee65bbd8765296cbd306cbfb2c5e1a392e2ba0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 5 May 2026 10:41:59 +0000 Subject: [PATCH] refactor(workflow): move IPC, worker, manager from daemon to @uncaged/workflow - Move workflow IPC types (StartThread, ResumeThread, etc.) to workflow/ipc.ts - Move workflow-worker.ts, workflow-manager.ts, workflow-manager-support.ts - Move worker-runtime.ts and worker-signals.ts (shared infrastructure) - Daemon now imports workflow runtime from @uncaged/workflow - Export WORKFLOW_WORKER_PATH for daemon to spawn workers Phase 3+4 of #320, Testing: #322 --- .cursor/rules/no-dynamic-import.mdc | 2 +- package.json | 2 +- packages/cli/package.json | 3 +- packages/cli/src/__tests__/e2e-harness.ts | 5 +- packages/core/src/agent.d.ts | 17 + packages/core/src/agent.d.ts.map | 1 + packages/core/src/config.d.ts | 63 ++++ packages/core/src/config.d.ts.map | 1 + packages/core/src/config.ts | 2 +- packages/core/src/daemon.d.ts | 120 +++++++ packages/core/src/daemon.d.ts.map | 1 + packages/core/src/index.d.ts | 21 ++ packages/core/src/index.d.ts.map | 1 + packages/core/src/index.ts | 4 +- packages/core/src/sense.d.ts | 39 +++ packages/core/src/sense.d.ts.map | 1 + packages/core/src/util.d.ts | 66 ++++ packages/core/src/util.d.ts.map | 1 + packages/daemon/package.json | 3 +- packages/daemon/rslib.config.ts | 1 - .../src/__tests__/crash-recovery.test.ts | 2 +- .../daemon/src/__tests__/hot-reload.test.ts | 2 +- .../src/__tests__/worker-runtime.test.ts | 2 +- .../src/__tests__/workflow-manager.test.ts | 2 +- packages/daemon/src/daemon-handlers.ts | 2 +- packages/daemon/src/index.ts | 6 +- packages/daemon/src/ipc.ts | 258 +++----------- packages/daemon/src/kernel-file-watch.ts | 2 +- packages/daemon/src/kernel.ts | 4 +- packages/daemon/src/worker-pool.ts | 2 +- packages/store/src/blob-store.d.ts | 17 + packages/store/src/blob-store.d.ts.map | 1 + packages/store/src/index.d.ts | 8 + packages/store/src/index.d.ts.map | 1 + packages/store/src/log-archive.d.ts | 32 ++ packages/store/src/log-archive.d.ts.map | 1 + packages/store/src/log-store.d.ts | 134 ++++++++ packages/store/src/log-store.d.ts.map | 1 + packages/workflow/package.json | 14 +- packages/workflow/rslib.config.ts | 1 + .../src/experimental-warning-suppression.ts | 23 ++ packages/workflow/src/index.ts | 34 ++ packages/workflow/src/ipc.ts | 314 ++++++++++++++++++ .../src/manager-support.ts} | 29 +- .../src/manager.ts} | 10 +- packages/workflow/src/paths.ts | 5 + packages/workflow/src/public-types.ts | 22 ++ .../src/worker-runtime.ts | 0 packages/workflow/src/worker-signals.ts | 17 + .../src/worker.ts} | 25 +- packages/workflow/tsconfig.public-types.json | 12 + pnpm-lock.yaml | 13 + 52 files changed, 1072 insertions(+), 278 deletions(-) create mode 100644 packages/core/src/agent.d.ts create mode 100644 packages/core/src/agent.d.ts.map create mode 100644 packages/core/src/config.d.ts create mode 100644 packages/core/src/config.d.ts.map create mode 100644 packages/core/src/daemon.d.ts create mode 100644 packages/core/src/daemon.d.ts.map create mode 100644 packages/core/src/index.d.ts create mode 100644 packages/core/src/index.d.ts.map create mode 100644 packages/core/src/sense.d.ts create mode 100644 packages/core/src/sense.d.ts.map create mode 100644 packages/core/src/util.d.ts create mode 100644 packages/core/src/util.d.ts.map create mode 100644 packages/store/src/blob-store.d.ts create mode 100644 packages/store/src/blob-store.d.ts.map create mode 100644 packages/store/src/index.d.ts create mode 100644 packages/store/src/index.d.ts.map create mode 100644 packages/store/src/log-archive.d.ts create mode 100644 packages/store/src/log-archive.d.ts.map create mode 100644 packages/store/src/log-store.d.ts create mode 100644 packages/store/src/log-store.d.ts.map create mode 100644 packages/workflow/src/experimental-warning-suppression.ts create mode 100644 packages/workflow/src/ipc.ts rename packages/{daemon/src/workflow-manager-support.ts => workflow/src/manager-support.ts} (90%) rename packages/{daemon/src/workflow-manager.ts => workflow/src/manager.ts} (98%) create mode 100644 packages/workflow/src/paths.ts create mode 100644 packages/workflow/src/public-types.ts rename packages/{daemon => workflow}/src/worker-runtime.ts (100%) create mode 100644 packages/workflow/src/worker-signals.ts rename packages/{daemon/src/workflow-worker.ts => workflow/src/worker.ts} (97%) create mode 100644 packages/workflow/tsconfig.public-types.json diff --git a/.cursor/rules/no-dynamic-import.mdc b/.cursor/rules/no-dynamic-import.mdc index 5378994..1cf0b50 100644 --- a/.cursor/rules/no-dynamic-import.mdc +++ b/.cursor/rules/no-dynamic-import.mdc @@ -20,7 +20,7 @@ Always use static top-level `import` statements. ## Exceptions (must include a comment explaining why) 1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime -2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime +2. **`packages/workflow/src/worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime When suppressing, add a comment directly above: diff --git a/package.json b/package.json index 064f41b..3917ea6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ }, "scripts": { "prepare": "husky", - "build": "pnpm -r run build", + "build": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build && pnpm -r --filter '!@uncaged/nerve-core' --filter '!@uncaged/nerve-store' --filter '!@uncaged/workflow' run build", "test": "pnpm -r test", "check": "biome check .", "format": "biome format --write .", diff --git a/packages/cli/package.json b/packages/cli/package.json index 60edfe2..5399da5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,7 +17,7 @@ "scripts": { "prepublishOnly": "bash ../../scripts/prepublish-check.sh", "build": "rslib build", - "pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-daemon run build", + "pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build && pnpm --filter @uncaged/nerve-daemon run build", "test": "vitest run" }, "dependencies": { @@ -31,6 +31,7 @@ "@rslib/core": "^0.21.3", "@types/node": "^22.0.0", "@uncaged/nerve-daemon": "workspace:*", + "@uncaged/workflow": "workspace:*", "vitest": "^4.1.5" } } diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index 67355b7..1f39c0a 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -51,8 +51,9 @@ import { workflowCommand } from "../commands/workflow.js"; const require = createRequire(import.meta.url); const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json")); +const nerveWorkflowRoot = dirname(require.resolve("@uncaged/workflow/package.json")); const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js"); -const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js"); +const workflowWorkerScript = join(nerveWorkflowRoot, "dist", "worker.js"); const nerveYamlTemplate = `senses: counter: @@ -274,7 +275,7 @@ export async function startTestDaemon( } if (!existsSync(workflowWorkerScript)) { throw new Error( - `Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\`.`, + `Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/workflow build\`.`, ); } diff --git a/packages/core/src/agent.d.ts b/packages/core/src/agent.d.ts new file mode 100644 index 0000000..c94c2a5 --- /dev/null +++ b/packages/core/src/agent.d.ts @@ -0,0 +1,17 @@ +/** + * Extract layer types — parses agent raw string output into typed meta (RFC-003). + */ +/** Structured meta validation descriptor for `ExtractFn`; concrete validators are provider-defined. */ +export type Schema = { + readonly witness: T | null; +}; +export type ExtractFn = (raw: string, schema: Schema) => Promise; +export declare class ExtractError extends Error { + readonly raw: string; + readonly causeError: Error | null; + constructor(message: string, detail: { + raw: string; + causeError: Error | null; + }); +} +//# sourceMappingURL=agent.d.ts.map \ No newline at end of file diff --git a/packages/core/src/agent.d.ts.map b/packages/core/src/agent.d.ts.map new file mode 100644 index 0000000..da8f9d5 --- /dev/null +++ b/packages/core/src/agent.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["agent.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,uGAAuG;AACvG,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI;IACtB,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,IAAI,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;AAE1E,qBAAa,YAAa,SAAQ,KAAK;IACrC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,KAAK,GAAG,IAAI,CAAC;gBAEtB,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,KAAK,GAAG,IAAI,CAAA;KAAE;CAO/E"} \ No newline at end of file diff --git a/packages/core/src/config.d.ts b/packages/core/src/config.d.ts new file mode 100644 index 0000000..55533a8 --- /dev/null +++ b/packages/core/src/config.d.ts @@ -0,0 +1,63 @@ +import { type DropOverflowConfig, type QueueOverflowConfig, type WorkflowConfig } from "@uncaged/workflow"; +import { type Result } from "./util.js"; +export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig }; +export type SenseConfig = { + group: string; + throttle: number | null; + timeout: number | null; + gracePeriod: number | null; + /** Polling interval (ms). When set, the sense is triggered periodically. */ + interval: number | null; + /** Other sense names whose successful computes schedule this sense (kernel reverse-index). */ + on: string[]; +}; +/** Optional HTTP control plane. When `port` is null, the HTTP server is not started. */ +export type NerveApiConfig = { + port: number | null; + /** When set, HTTP API requires `Authorization: Bearer `. */ + token: string | null; + /** Bind address (e.g. `127.0.0.1`, `0.0.0.0`). Meaningful when `port` is set. */ + host: string; +}; +/** Adapter factory input (model, timeout); used by adapter packages (RFC-003). */ +export type AgentConfig = { + /** Adapter id (e.g. `cursor`, `hermes`, `echo`) — informational for factories that branch on type. */ + type: string; + /** Model id or `"auto"` for adapter defaults. */ + model: string; + /** Wall-clock cap in milliseconds, or `null` for adapter-specific default. */ + timeout: number | null; +}; +/** Global extract provider for typed meta from agent raw output (RFC-003). */ +export type ExtractConfig = { + provider: string; + model: string; +}; +/** + * Optional shell side effect after a successful sense `compute()`. + * Executed in the sense worker (`spawn` with `shell: true`, cwd = nerve root). + * Workflows are started only via CLI / daemon IPC, not from sense compute results. + */ +export type SenseTrigger = { + command: string; +}; +export type NerveConfig = { + /** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */ + maxRounds: number; + senses: Record; + workflows: Record; + api: NerveApiConfig; + /** Global extract defaults; `null` when the section is omitted. */ + extract: ExtractConfig | null; +}; +export type KnowledgeConfig = { + include: ReadonlyArray; + exclude: ReadonlyArray; +}; +export declare function parseNerveConfig(raw: string): Result; +/** + * Parse `knowledge.yaml` at the repo root (RFC-003 Knowledge Layer). + * `include` / `exclude` entries are glob patterns resolved against the repo root. + */ +export declare function parseKnowledgeYaml(raw: string): Result; +//# sourceMappingURL=config.d.ts.map \ No newline at end of file diff --git a/packages/core/src/config.d.ts.map b/packages/core/src/config.d.ts.map new file mode 100644 index 0000000..e59e34c --- /dev/null +++ b/packages/core/src/config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,KAAK,MAAM,EAAmD,MAAM,WAAW,CAAC;AAEzF,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,cAAc,EAAE,CAAC;AAExE,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,8FAA8F;IAC9F,EAAE,EAAE,MAAM,EAAE,CAAC;CACd,CAAC;AAEF,wFAAwF;AACxF,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,mEAAmE;IACnE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,kFAAkF;AAClF,MAAM,MAAM,WAAW,GAAG;IACxB,sGAAsG;IACtG,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CAAC;AAEF,8EAA8E;AAC9E,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,yFAAyF;IACzF,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC1C,GAAG,EAAE,cAAc,CAAC;IACpB,mEAAmE;IACnE,OAAO,EAAE,aAAa,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAChC,CAAC;AAuQF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,CAwDjE;AAoBD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC,eAAe,CAAC,CA8BvE"} \ No newline at end of file diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 045d8b6..292ea6f 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -5,7 +5,7 @@ import { type DropOverflowConfig, type QueueOverflowConfig, type WorkflowConfig, -} from "@uncaged/workflow"; +} from "@uncaged/workflow/public-types"; import { type Result, err, isPlainRecord, ok, parseDurationStringToMs } from "./util.js"; export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig }; diff --git a/packages/core/src/daemon.d.ts b/packages/core/src/daemon.d.ts new file mode 100644 index 0000000..0746d31 --- /dev/null +++ b/packages/core/src/daemon.d.ts @@ -0,0 +1,120 @@ +/** + * 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 type { SenseInfo } from "./sense.js"; +/** Runtime status of a registered workflow (for listing / observability). */ +export type WorkflowStatus = { + name: string; + activeThreads: number; + /** Run IDs currently executing (same identifiers accepted by kill-workflow). */ + activeRunIds: string[]; + queuedThreads: number; + config: { + concurrency: number; + overflow: string; + }; +}; +/** Public health payload for HTTP / IPC. */ +export type HealthInfo = { + ok: boolean; + version: string; + uptime: number; + startedAt: string; + hostname: string; +}; +/** 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; +}; +/** Client → daemon: list registered workflows and queue/active counts. */ +export type DaemonIpcListWorkflowsRequest = { + type: "list-workflows"; +}; +/** Client → daemon: public health snapshot. */ +export type DaemonIpcHealthRequest = { + type: "health"; +}; +/** Union of all JSON requests the daemon IPC server accepts. */ +export type DaemonIpcRequest = DaemonIpcTriggerWorkflowRequest | DaemonIpcTriggerSenseRequest | DaemonIpcListSensesRequest | DaemonIpcKillWorkflowRequest | DaemonIpcListWorkflowsRequest | DaemonIpcHealthRequest; +/** 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; +/** Reply for list-workflows. */ +export type DaemonIpcListWorkflowsResponse = { + ok: true; + workflows: WorkflowStatus[]; +} | DaemonIpcErrorResponse; +/** Reply for health. */ +export type DaemonIpcHealthResponse = { + ok: true; + health: HealthInfo; +} | DaemonIpcErrorResponse; +/** Any JSON response the daemon may write on the IPC socket. */ +export type DaemonIpcResponse = DaemonIpcTriggerOkResponse | DaemonIpcErrorResponse | DaemonIpcListSensesResponse | DaemonIpcListWorkflowsResponse | DaemonIpcHealthResponse; +export type DaemonTransportTriggerResult = { + ok: true; +} | { + ok: false; + error: string; +}; +export type DaemonTransportWorkflowLaunch = { + prompt: string; + maxRounds: number; + dryRun: boolean; +}; +/** + * Abstraction over daemon control plane (Unix socket IPC today, HTTP in Phase 2). + * Implementations live in CLI / tools; the daemon kernel uses shared handler logic. + */ +export type DaemonTransport = { + health(): Promise; + listSenses(): Promise; + listWorkflows(): Promise; + triggerSense(name: string): Promise; + /** When `launch` is null, implementations use engine defaults (empty prompt, default max rounds, dryRun false). */ + triggerWorkflow(name: string, launch: DaemonTransportWorkflowLaunch | null): Promise; + /** Kill a running or queued workflow thread by `runId` (same field as IPC `kill-workflow`). */ + killWorkflow(runId: string): Promise; +}; +/** + * 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 declare function parseDaemonIpcRequest(line: string): DaemonIpcRequest | null; +/** Type guard for JSON {@link SenseInfo} payloads from daemon HTTP/IPC. */ +export declare function isSenseInfo(value: unknown): value is SenseInfo; +/** Type guard for JSON {@link WorkflowStatus} payloads from daemon HTTP/IPC. */ +export declare function isWorkflowStatus(value: unknown): value is WorkflowStatus; +//# sourceMappingURL=daemon.d.ts.map \ No newline at end of file diff --git a/packages/core/src/daemon.d.ts.map b/packages/core/src/daemon.d.ts.map new file mode 100644 index 0000000..3ce4770 --- /dev/null +++ b/packages/core/src/daemon.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["daemon.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts new file mode 100644 index 0000000..a73caa9 --- /dev/null +++ b/packages/core/src/index.d.ts @@ -0,0 +1,21 @@ +export type { SenseConfig, DropOverflowConfig, QueueOverflowConfig, WorkflowConfig, NerveApiConfig, AgentConfig, ExtractConfig, NerveConfig, SenseTrigger, } from "./config.js"; +export type { SenseInfo } from "./sense.js"; +export type { SenseComputeFn, SenseModule } from "./sense.js"; +export { senseTriggerLabels } from "./sense.js"; +export type { WorkflowMessage, RoleResult, Role, RoleMeta, StartStep, ThreadContext, WorkflowContext, AgentFn, RoleStep, ModeratorContext, Moderator, WorkflowDefinition, } from "@uncaged/workflow"; +export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow"; +export type { Schema, ExtractFn } from "./agent.js"; +export { ExtractError } from "./agent.js"; +export type { Result } from "./util.js"; +export { ok, err } from "./util.js"; +export { nerveCommandEnv, spawnSafe, type SpawnEnv, type SpawnError, type SpawnResult, type SpawnSafeOptions, } from "./util.js"; +export { parseNerveConfig } from "./config.js"; +export type { KnowledgeConfig } from "./config.js"; +export { parseKnowledgeYaml } from "./config.js"; +export { isPlainRecord } from "./util.js"; +export { parseSenseTrigger } from "./sense.js"; +export { isSenseInfo, isWorkflowStatus } from "./daemon.js"; +export type { WorkflowStatus, HealthInfo, DaemonIpcTriggerWorkflowRequest, DaemonIpcTriggerSenseRequest, DaemonIpcListSensesRequest, DaemonIpcKillWorkflowRequest, DaemonIpcListWorkflowsRequest, DaemonIpcHealthRequest, DaemonIpcRequest, DaemonIpcTriggerOkResponse, DaemonIpcErrorResponse, DaemonIpcTriggerResponse, DaemonIpcListSensesResponse, DaemonIpcListWorkflowsResponse, DaemonIpcHealthResponse, DaemonIpcResponse, } from "./daemon.js"; +export { parseDaemonIpcRequest } from "./daemon.js"; +export type { DaemonTransport, DaemonTransportTriggerResult, DaemonTransportWorkflowLaunch, } from "./daemon.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/core/src/index.d.ts.map b/packages/core/src/index.d.ts.map new file mode 100644 index 0000000..3af0e82 --- /dev/null +++ b/packages/core/src/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,WAAW,EACX,kBAAkB,EAClB,mBAAmB,EACnB,cAAc,EACd,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,YAAY,GACb,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,YAAY,EACV,eAAe,EACf,UAAU,EACV,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,aAAa,EACb,eAAe,EACf,OAAO,EACP,QAAQ,EACR,gBAAgB,EAChB,SAAS,EACT,kBAAkB,GACnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,yBAAyB,EAAE,MAAM,mBAAmB,CAAC;AAC1E,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,YAAY,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,EAAE,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EACL,eAAe,EACf,SAAS,EACT,KAAK,QAAQ,EACb,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,gBAAgB,GACtB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE1C,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE/C,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC5D,YAAY,EACV,cAAc,EACd,UAAU,EACV,+BAA+B,EAC/B,4BAA4B,EAC5B,0BAA0B,EAC1B,4BAA4B,EAC5B,6BAA6B,EAC7B,sBAAsB,EACtB,gBAAgB,EAChB,0BAA0B,EAC1B,sBAAsB,EACtB,wBAAwB,EACxB,2BAA2B,EAC3B,8BAA8B,EAC9B,uBAAuB,EACvB,iBAAiB,GAClB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpD,YAAY,EACV,eAAe,EACf,4BAA4B,EAC5B,6BAA6B,GAC9B,MAAM,aAAa,CAAC"} \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e57283c..b0b8f3e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,8 +25,8 @@ export type { ModeratorContext, Moderator, WorkflowDefinition, -} from "@uncaged/workflow"; -export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow"; +} from "@uncaged/workflow/public-types"; +export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow/public-types"; export type { Schema, ExtractFn } from "./agent.js"; export { ExtractError } from "./agent.js"; export type { Result } from "./util.js"; diff --git a/packages/core/src/sense.d.ts b/packages/core/src/sense.d.ts new file mode 100644 index 0000000..5562d57 --- /dev/null +++ b/packages/core/src/sense.d.ts @@ -0,0 +1,39 @@ +import type { SenseConfig, SenseTrigger } from "./config.js"; +import { type Result } from "./util.js"; +/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */ +export type SenseInfo = { + name: string; + group: string; + throttle: number | null; + timeout: number | null; + /** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */ + triggers: ReadonlyArray; +}; +/** + * The function signature every sense `src/index.ts` must export as a named + * `compute` export. + * + * Pure: no DB, no peers. + * Returns the next sense state and an optional trigger (`trigger: null` means no side effect). + */ +export type SenseComputeFn = (state: S) => Promise<{ + state: S; + trigger: SenseTrigger | null; +}>; +/** + * The full shape a sense module (`src/index.ts`) must export. + */ +export type SenseModule = { + compute: SenseComputeFn; + initialState: S; +}; +/** Human-readable label for a sense schedule (`interval` and/or `on`). */ +export declare function labelSenseTrigger(slice: Pick): string; +/** + * Human-readable trigger labels for a sense from its `SenseConfig.interval` / `.on`. + * Returns an empty array when the sense is missing or has no schedule. + */ +export declare function senseTriggerLabels(senseName: string, senses: Record): string[]; +/** Validates `{ command: string }` from Sense compute or IPC (`trigger` field). */ +export declare function parseSenseTrigger(value: unknown): Result; +//# sourceMappingURL=sense.d.ts.map \ No newline at end of file diff --git a/packages/core/src/sense.d.ts.map b/packages/core/src/sense.d.ts.map new file mode 100644 index 0000000..4419f25 --- /dev/null +++ b/packages/core/src/sense.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"sense.d.ts","sourceRoot":"","sources":["sense.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAAE,KAAK,MAAM,EAA0B,MAAM,WAAW,CAAC;AAEhE,kEAAkE;AAClE,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,yFAAyF;IACzF,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CACjC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,GAAG,OAAO,IAAI,CACxC,KAAK,EAAE,CAAC,KACL,OAAO,CAAC;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAAA;CAAE,CAAC,CAAC;AAEzD;;GAEG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,GAAG,OAAO,IAAI;IACrC,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;IAC3B,YAAY,EAAE,CAAC,CAAC;CACjB,CAAC;AAYF,0EAA0E;AAC1E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,UAAU,GAAG,IAAI,CAAC,GAAG,MAAM,CAYrF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAClC,MAAM,EAAE,CAKV;AAED,mFAAmF;AACnF,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC,YAAY,CAAC,CActE"} \ No newline at end of file diff --git a/packages/core/src/util.d.ts b/packages/core/src/util.d.ts new file mode 100644 index 0000000..560d9a3 --- /dev/null +++ b/packages/core/src/util.d.ts @@ -0,0 +1,66 @@ +export type Result = { + ok: true; + value: T; +} | { + ok: false; + error: E; +}; +/** Compatible with `process.env` for `child_process.spawn`. */ +export type SpawnEnv = Record; +export type SpawnResult = { + stdout: string; + stderr: string; + exitCode: number; + /** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */ + signal: string | null; +}; +export type SpawnError = { + kind: "non_zero_exit"; + stdout: string; + stderr: string; + exitCode: number; + signal: string | null; +} | { + kind: "timeout"; + stdout: string; + stderr: string; +} | { + kind: "spawn_failed"; + message: string; +} | { + kind: "aborted"; +}; +export type SpawnSafeOptions = { + cwd: string | null; + /** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */ + env: SpawnEnv | null; + timeoutMs: number | null; + dryRun: boolean; + /** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */ + abortSignal: AbortSignal | null; +}; +type SpawnSafeOptionsInput = SpawnSafeOptions | Omit; +export declare function ok(value: T): Result; +export declare function err(error: E): Result; +/** + * 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 declare function isPlainRecord(value: unknown): value is Record; +/** + * Parse a duration string such as `5s`, `10m`, `1h` to milliseconds. + * Used by `parseNerveConfig` sense/workflow duration fields. + */ +export declare function parseDurationStringToMs(value: string): Result; +/** + * PATH and PNPM_HOME for running `pnpm` and `nerve` from workflow roles. + * Uses the pnpm store home only (no npm user bin); binaries must resolve via PATH. + */ +export declare function nerveCommandEnv(): SpawnEnv; +/** + * Spawn a process with `shell: false` (argv only), default {@link nerveCommandEnv}, and optional timeout. + * Returns `ok` only when the process exits with code 0. + */ +export declare function spawnSafe(command: string, args: ReadonlyArray, options: SpawnSafeOptionsInput): Promise>; +export {}; +//# sourceMappingURL=util.d.ts.map \ No newline at end of file diff --git a/packages/core/src/util.d.ts.map b/packages/core/src/util.d.ts.map new file mode 100644 index 0000000..8f637fb --- /dev/null +++ b/packages/core/src/util.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["util.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,MAAM,CAAC,CAAC,EAAE,CAAC,GAAG,KAAK,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,CAAC;AAEpF,+DAA+D;AAC/D,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAE1D,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,UAAU,GAClB;IACE,IAAI,EAAE,eAAe,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,GACD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAExB,MAAM,MAAM,gBAAgB,GAAG;IAC7B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,wGAAwG;IACxG,GAAG,EAAE,QAAQ,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,OAAO,CAAC;IAChB,sHAAsH;IACtH,WAAW,EAAE,WAAW,GAAG,IAAI,CAAC;CACjC,CAAC;AAEF,KAAK,qBAAqB,GAAG,gBAAgB,GAAG,IAAI,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;AAIjF,wBAAgB,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAEhD;AAED,wBAAgB,GAAG,CAAC,CAAC,GAAG,KAAK,EAAE,KAAK,EAAE,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAEzD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE9E;AAUD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAMrE;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,QAAQ,CAQ1C;AAgCD;;;GAGG;AACH,wBAAgB,SAAS,CACvB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,EAC3B,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC,CAoG1C"} \ No newline at end of file diff --git a/packages/daemon/package.json b/packages/daemon/package.json index e205d1d..73dcc97 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -22,10 +22,11 @@ "scripts": { "prepublishOnly": "bash ../../scripts/prepublish-check.sh", "build": "rslib build", - "pretest": "pnpm --filter @uncaged/nerve-core run build", + "pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build", "test": "vitest run" }, "dependencies": { + "@uncaged/workflow": "workspace:*", "@uncaged/nerve-core": "workspace:*", "@uncaged/nerve-store": "workspace:*", "yaml": "^2.8.3" diff --git a/packages/daemon/rslib.config.ts b/packages/daemon/rslib.config.ts index ee4c746..ef3ab7c 100644 --- a/packages/daemon/rslib.config.ts +++ b/packages/daemon/rslib.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ entry: { index: "src/index.ts", "sense-worker": "src/sense-worker.ts", - "workflow-worker": "src/workflow-worker.ts", "experimental-warning-suppression": "src/experimental-warning-suppression.ts", }, }, diff --git a/packages/daemon/src/__tests__/crash-recovery.test.ts b/packages/daemon/src/__tests__/crash-recovery.test.ts index 4891997..fee159c 100644 --- a/packages/daemon/src/__tests__/crash-recovery.test.ts +++ b/packages/daemon/src/__tests__/crash-recovery.test.ts @@ -62,7 +62,7 @@ vi.mock("node:child_process", () => ({ }), })); -const { createWorkflowManager } = await import("../workflow-manager.js"); +const { createWorkflowManager } = await import("@uncaged/workflow"); function makeConfig(workflows: Record = {}): NerveConfig { return { diff --git a/packages/daemon/src/__tests__/hot-reload.test.ts b/packages/daemon/src/__tests__/hot-reload.test.ts index 678affe..7225d8f 100644 --- a/packages/daemon/src/__tests__/hot-reload.test.ts +++ b/packages/daemon/src/__tests__/hot-reload.test.ts @@ -67,7 +67,7 @@ vi.mock("node:child_process", () => ({ }), })); -const { createWorkflowManager } = await import("../workflow-manager.js"); +const { createWorkflowManager } = await import("@uncaged/workflow"); const { createKernel } = await import("../kernel.js"); function makeWfConfig(workflows: Record = {}): NerveConfig { diff --git a/packages/daemon/src/__tests__/worker-runtime.test.ts b/packages/daemon/src/__tests__/worker-runtime.test.ts index 642a33e..e500b98 100644 --- a/packages/daemon/src/__tests__/worker-runtime.test.ts +++ b/packages/daemon/src/__tests__/worker-runtime.test.ts @@ -1,7 +1,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createWorkerRuntime } from "../worker-runtime.js"; +import { createWorkerRuntime } from "@uncaged/workflow"; const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); const echoWorkerPath = join(fixturesDir, "echo-worker.js"); diff --git a/packages/daemon/src/__tests__/workflow-manager.test.ts b/packages/daemon/src/__tests__/workflow-manager.test.ts index 280ad41..ed8b0ee 100644 --- a/packages/daemon/src/__tests__/workflow-manager.test.ts +++ b/packages/daemon/src/__tests__/workflow-manager.test.ts @@ -61,7 +61,7 @@ vi.mock("node:child_process", () => ({ })); // Import after mock is set up -const { createWorkflowManager } = await import("../workflow-manager.js"); +const { createWorkflowManager } = await import("@uncaged/workflow"); // --------------------------------------------------------------------------- // Helpers diff --git a/packages/daemon/src/daemon-handlers.ts b/packages/daemon/src/daemon-handlers.ts index bd97d5e..6f34644 100644 --- a/packages/daemon/src/daemon-handlers.ts +++ b/packages/daemon/src/daemon-handlers.ts @@ -1,6 +1,6 @@ import type { HealthInfo, SenseInfo, WorkflowStatus } from "@uncaged/nerve-core"; -import type { WorkflowManager } from "./workflow-manager.js"; +import type { WorkflowManager } from "@uncaged/workflow"; export type DaemonHandlerBundle = { health: () => HealthInfo; diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 0d88a47..64f844b 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -12,7 +12,7 @@ export type { ResumeThreadMessage, ThreadEventMessage, WorkflowErrorMessage, - ThreadWorkflowMessageMessage, + ThreadWorkflowMessage, } from "./ipc.js"; export { loadSenseModule, executeCompute, readState, writeState } from "./sense-runtime.js"; @@ -45,5 +45,5 @@ export type { WorkflowRunStatus, } from "@uncaged/nerve-store"; -export { createWorkflowManager } from "./workflow-manager.js"; -export type { WorkflowManager } from "./workflow-manager.js"; +export { createWorkflowManager } from "@uncaged/workflow"; +export type { WorkflowManager } from "@uncaged/workflow"; diff --git a/packages/daemon/src/ipc.ts b/packages/daemon/src/ipc.ts index e23f692..d38a217 100644 --- a/packages/daemon/src/ipc.ts +++ b/packages/daemon/src/ipc.ts @@ -1,10 +1,30 @@ /** * IPC message types for parent (kernel) ↔ sense worker communication. * Protocol per RFC §5.2: hub-and-spoke, all messages through engine. + * + * Workflow worker IPC types and parsers live in `@uncaged/workflow`. */ import type { Result, SenseTrigger } from "@uncaged/nerve-core"; import { err, isPlainRecord, ok, parseSenseTrigger } from "@uncaged/nerve-core"; +import type { + KillThreadMessage, + ResumeThreadMessage, + StartThreadMessage, + ThreadEventMessage, + ThreadWorkflowMessage, + WorkflowErrorMessage, +} from "@uncaged/workflow"; +import { parseWorkflowParentMessage, parseWorkflowWorkerToParentMessage } from "@uncaged/workflow"; + +export type { + KillThreadMessage, + ResumeThreadMessage, + StartThreadMessage, + ThreadEventMessage, + ThreadWorkflowMessage, + WorkflowErrorMessage, +} from "@uncaged/workflow"; /** Parent → Worker: trigger one compute cycle for a sense */ export type ComputeMessage = { @@ -22,40 +42,6 @@ export type HealthRequestMessage = { type: "health-request"; }; -// --------------------------------------------------------------------------- -// Workflow IPC messages (RFC-002 §5.2) -// --------------------------------------------------------------------------- - -/** Parent → Workflow Worker: start a new thread */ -export type StartThreadMessage = { - type: "start-thread"; - runId: string; - workflow: string; - prompt: string; - /** Safety-valve: max moderator rounds for this thread (engine launch parameter). */ - maxRounds: number; - /** When true, roles may skip side effects (thread-level hint on the start frame). */ - dryRun: boolean; -}; - -/** Parent → Workflow Worker: resume an existing thread after crash recovery */ -export type ResumeThreadMessage = { - type: "resume-thread"; - runId: string; - /** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */ - messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>; - /** Safety-valve: max moderator rounds for this thread. */ - maxRounds: number; - /** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */ - dryRun: boolean; -}; - -/** Parent → Workflow Worker: kill a specific running thread */ -export type KillThreadMessage = { - type: "kill-thread"; - runId: string; -}; - /** Union of all messages the parent sends to a worker */ export type ParentToWorkerMessage = | ComputeMessage @@ -92,46 +78,6 @@ export type HealthResponseMessage = { inFlightCount: number; }; -// --------------------------------------------------------------------------- -// Workflow Worker → Parent messages (RFC-002 §5.2) -// --------------------------------------------------------------------------- - -/** Valid lifecycle event types for a workflow thread. */ -export type ThreadEventType = - | "queued" - | "started" - | "step_complete" - | "completed" - | "failed" - | "killed"; - -/** - * Workflow Worker → Parent: a thread lifecycle event. - */ -export type ThreadEventMessage = { - type: "thread-event"; - runId: string; - eventType: ThreadEventType; - payload: unknown; -}; - -/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */ -export type WorkflowErrorMessage = { - type: "workflow-error"; - runId: string; - error: string; - /** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */ - exitCode: number; -}; - -/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */ -export type ThreadWorkflowMessageMessage = { - type: "thread-workflow-message"; - runId: string; - /** The WorkflowMessage produced by the role — persisted for crash recovery. */ - message: { role: string; content: string; meta: unknown; timestamp: number }; -}; - /** Union of all messages a worker sends to the parent */ export type WorkerToParentMessage = | ComputeResultMessage @@ -140,7 +86,7 @@ export type WorkerToParentMessage = | HealthResponseMessage | ThreadEventMessage | WorkflowErrorMessage - | ThreadWorkflowMessageMessage; + | ThreadWorkflowMessage; const PARENT_MSG_TYPES = new Set([ "compute", @@ -151,24 +97,6 @@ const PARENT_MSG_TYPES = new Set([ "kill-thread", ]); -function validateStartThreadMsg(obj: Record): string | 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 | null { - if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'"; - if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array"; - if (typeof obj.maxRounds !== "number") - return "'resume-thread' message missing number 'maxRounds'"; - if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'"; - return null; -} - function parseParentCompute(obj: Record): Result { if (typeof obj.sense !== "string") { return err(new Error("IPC 'compute' message missing string 'sense' field")); @@ -176,40 +104,6 @@ function parseParentCompute(obj: Record): Result): Result { - const errMsg = validateStartThreadMsg(obj); - if (errMsg !== null) return err(new Error(errMsg)); - // Field types are validated above; `Record` values stay `unknown` to TypeScript. - return ok({ - type: "start-thread", - runId: obj.runId, - workflow: obj.workflow, - prompt: obj.prompt, - maxRounds: obj.maxRounds, - dryRun: obj.dryRun, - } as StartThreadMessage); -} - -function parseParentResumeThread(obj: Record): Result { - const errMsg = validateResumeThreadMsg(obj); - if (errMsg !== null) return err(new Error(errMsg)); - // Elements are validated as plain objects by the kernel; trust the wire shape here. - return ok({ - type: "resume-thread", - runId: obj.runId, - messages: obj.messages as ResumeThreadMessage["messages"], - maxRounds: obj.maxRounds, - dryRun: obj.dryRun, - } as ResumeThreadMessage); -} - -function parseParentKillThread(obj: Record): Result { - if (typeof obj.runId !== "string") { - return err(new Error("'kill-thread' message missing string 'runId'")); - } - return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage); -} - /** Validate and parse an unknown IPC message received from the parent process. */ export function parseParentMessage(raw: unknown): Result { if (!isPlainRecord(raw)) { @@ -230,11 +124,14 @@ export function parseParentMessage(raw: unknown): Result case "health-request": return ok({ type: "health-request" }); case "start-thread": - return parseParentStartThread(obj); case "resume-thread": - return parseParentResumeThread(obj); - case "kill-thread": - return parseParentKillThread(obj); + case "kill-thread": { + const wf = parseWorkflowParentMessage(raw); + if (!wf.ok) { + return wf; + } + return ok(wf.value as ParentToWorkerMessage); + } default: return err(new Error(`Unhandled IPC message type: "${obj.type}"`)); } @@ -299,55 +196,11 @@ function parseHealthResponseMsg(obj: Record): Result): Result { - 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): Result { - if (typeof obj.runId !== "string") { - return err(new Error("Worker 'workflow-error' message missing string 'runId' field")); - } - if (typeof obj.error !== "string") { - return err(new Error("Worker 'workflow-error' message missing string 'error' field")); - } - const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1; - return ok({ - type: "workflow-error", - runId: obj.runId, - error: obj.error, - exitCode, - }); -} +const WORKFLOW_WORKER_MSG_TYPES = new Set([ + "thread-event", + "workflow-error", + "thread-workflow-message", +]); const WORKER_MSG_TYPES = new Set([ "compute-result", @@ -359,41 +212,6 @@ const WORKER_MSG_TYPES = new Set([ "thread-workflow-message", ]); -function parseThreadWorkflowMessageMsg( - obj: Record, -): Result { - 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 { if (!isPlainRecord(raw)) { @@ -406,11 +224,15 @@ export function parseWorkerMessage(raw: unknown): Result if (!WORKER_MSG_TYPES.has(obj.type)) { return err(new Error(`Unknown worker IPC message type: "${obj.type}"`)); } + if (WORKFLOW_WORKER_MSG_TYPES.has(obj.type)) { + const wf = parseWorkflowWorkerToParentMessage(raw); + if (!wf.ok) { + return wf; + } + return ok(wf.value as WorkerToParentMessage); + } if (obj.type === "compute-result") return parseComputeResultMsg(obj); if (obj.type === "error") return parseErrorMsg(obj); if (obj.type === "health-response") return parseHealthResponseMsg(obj); - if (obj.type === "thread-event") return parseThreadEventMsg(obj); - if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj); - if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj); return ok({ type: "ready" }); } diff --git a/packages/daemon/src/kernel-file-watch.ts b/packages/daemon/src/kernel-file-watch.ts index f0b353e..aec5c2e 100644 --- a/packages/daemon/src/kernel-file-watch.ts +++ b/packages/daemon/src/kernel-file-watch.ts @@ -9,7 +9,7 @@ import type { NerveConfig } from "@uncaged/nerve-core"; import { parseNerveConfig } from "@uncaged/nerve-core"; import type { LogStore } from "@uncaged/nerve-store"; -import type { WorkflowManager } from "./workflow-manager.js"; +import type { WorkflowManager } from "@uncaged/workflow"; export type KernelFileWatchDeps = { nerveRoot: string; diff --git a/packages/daemon/src/kernel.ts b/packages/daemon/src/kernel.ts index d411a84..558529e 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -36,8 +36,8 @@ import { import { createSenseScheduler } from "./sense-scheduler.js"; import type { SenseScheduler } from "./sense-scheduler.js"; import { createSenseWorkerPool, resolveWorkerScript } from "./worker-pool.js"; -import { createWorkflowManager } from "./workflow-manager.js"; -import type { WorkflowManager } from "./workflow-manager.js"; +import { createWorkflowManager } from "@uncaged/workflow"; +import type { WorkflowManager } from "@uncaged/workflow"; export type KernelHealth = { uptime: number; diff --git a/packages/daemon/src/worker-pool.ts b/packages/daemon/src/worker-pool.ts index d93560f..03d8dab 100644 --- a/packages/daemon/src/worker-pool.ts +++ b/packages/daemon/src/worker-pool.ts @@ -10,7 +10,7 @@ import { createWorkerRuntime, formatCapturedStderrTail, formatChildExitSummary, -} from "./worker-runtime.js"; +} from "@uncaged/workflow"; export function resolveWorkerScript(): string { const __filename = fileURLToPath(import.meta.url); diff --git a/packages/store/src/blob-store.d.ts b/packages/store/src/blob-store.d.ts new file mode 100644 index 0000000..605c004 --- /dev/null +++ b/packages/store/src/blob-store.d.ts @@ -0,0 +1,17 @@ +/** + * CAS blob store — sha256 content-addressable files under `data/blobs/`. + * + * Layout: `/<2-hex-shard>/<62-hex-rest>` (RFC-001 §8). + */ +export type BlobStore = { + /** Persist UTF-8 or raw bytes; returns lowercase hex sha256. Idempotent for identical content. */ + write: (content: string | Uint8Array | Buffer) => string; + /** Returns bytes or null if the hash is invalid or no blob exists. Verifies digest matches path. */ + read: (hash: string) => Buffer | null; + /** True when hash is well-formed and the blob file is present. */ + exists: (hash: string) => boolean; +}; +/** @returns normalized lowercase hex or null if not a valid sha256 hex string */ +export declare function normalizeBlobHash(hash: string): string | null; +export declare function createBlobStore(blobsRoot: string): BlobStore; +//# sourceMappingURL=blob-store.d.ts.map \ No newline at end of file diff --git a/packages/store/src/blob-store.d.ts.map b/packages/store/src/blob-store.d.ts.map new file mode 100644 index 0000000..d2169b0 --- /dev/null +++ b/packages/store/src/blob-store.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"blob-store.d.ts","sourceRoot":"","sources":["blob-store.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAgBH,MAAM,MAAM,SAAS,GAAG;IACtB,kGAAkG;IAClG,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,KAAK,MAAM,CAAC;IACzD,oGAAoG;IACpG,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IACtC,kEAAkE;IAClE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;CACnC,CAAC;AAYF,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK7D;AAiBD,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CA2C5D"} \ No newline at end of file diff --git a/packages/store/src/index.d.ts b/packages/store/src/index.d.ts new file mode 100644 index 0000000..2834dbd --- /dev/null +++ b/packages/store/src/index.d.ts @@ -0,0 +1,8 @@ +/** + * @uncaged/nerve-store — append-only log storage, cold-archive helpers, CAS blob store. + */ +export * from "./blob-store.js"; +export * from "./log-archive.js"; +export { createLogStore } from "./log-store.js"; +export type { GetThreadRoundsParams, LogEntry, LogQuery, LogStore, ThreadRoundRow, WorkflowRun, WorkflowRunStatus, } from "./log-store.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/store/src/index.d.ts.map b/packages/store/src/index.d.ts.map new file mode 100644 index 0000000..5946df7 --- /dev/null +++ b/packages/store/src/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,YAAY,EACV,qBAAqB,EACrB,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,cAAc,EACd,WAAW,EACX,iBAAiB,GAClB,MAAM,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/store/src/log-archive.d.ts b/packages/store/src/log-archive.d.ts new file mode 100644 index 0000000..b7a6ee1 --- /dev/null +++ b/packages/store/src/log-archive.d.ts @@ -0,0 +1,32 @@ +/** Log cold-archive helpers (RFC-001 §5.4) — UTC calendar days, JSONL export. */ +export declare const LOG_ARCHIVE_META_KEY = "archived_up_to"; +export declare const DEFAULT_LOG_RETENTION_MS: number; +export type ArchiveLogsOptions = { + /** Wall clock for retention boundary (default: `Date.now()`). */ + now?: number; + /** Run `VACUUM` after archiving (outside the per-day transaction). */ + vacuum?: boolean; + /** Max UTC days to process in one call (default: unlimited). */ + maxDays?: number; + /** Override default 30-day retention (tests). */ + retentionMs?: number; +}; +export type ArchiveLogsDayResult = { + day: string; + rowCount: number; + filePath: string; +}; +export type ArchiveLogsResult = { + days: ArchiveLogsDayResult[]; + vacuumed: boolean; +}; +export declare function utcDateStringFromMs(ms: number): string; +export declare function assertValidUtcDay(day: string): void; +export declare function utcDayStartMs(day: string): number; +export declare function utcDayEndExclusiveMs(day: string): number; +export declare function prevUtcDay(day: string): string; +export declare function nextUtcDay(day: string): string; +/** Last UTC calendar day D such that the exclusive end of D is ≤ boundaryMs. */ +export declare function lastArchivableUtcDay(boundaryMs: number): string; +export declare function compareIsoDays(a: string, b: string): number; +//# sourceMappingURL=log-archive.d.ts.map \ No newline at end of file diff --git a/packages/store/src/log-archive.d.ts.map b/packages/store/src/log-archive.d.ts.map new file mode 100644 index 0000000..f4d0fc1 --- /dev/null +++ b/packages/store/src/log-archive.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"log-archive.d.ts","sourceRoot":"","sources":["log-archive.ts"],"names":[],"mappings":"AAAA,iFAAiF;AAEjF,eAAO,MAAM,oBAAoB,mBAAmB,CAAC;AAErD,eAAO,MAAM,wBAAwB,QAAkB,CAAC;AAExD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,iEAAiE;IACjE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,oBAAoB,EAAE,CAAC;IAC7B,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEtD;AAiBD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAEnD;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGjD;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,gFAAgF;AAChF,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAI3D"} \ No newline at end of file diff --git a/packages/store/src/log-store.d.ts b/packages/store/src/log-store.d.ts new file mode 100644 index 0000000..861bbd9 --- /dev/null +++ b/packages/store/src/log-store.d.ts @@ -0,0 +1,134 @@ +/** + * Log Store — append-only structured log storage backed by SQLite. + * + * Stores system, sense-scheduler (`sense_scheduler` source), sense, and workflow log entries in a single table. + * Logs are data assets for audit/analysis — they MUST NOT feed back into scheduling or workflows as triggers. + * + * Also provides a `meta` key-value table for bookkeeping (e.g. archive watermarks). + */ +import type { ArchiveLogsOptions, ArchiveLogsResult } from "./log-archive.js"; +export { LOG_ARCHIVE_META_KEY } from "./log-archive.js"; +export type { ArchiveLogsDayResult, ArchiveLogsOptions, ArchiveLogsResult } from "./log-archive.js"; +export type LogEntry = { + id?: number; + source: string; + type: string; + refId: string | null; + payload: string | null; + timestamp: number; +}; +export type LogQuery = { + source?: string; + type?: string; + refId?: string; + since?: number; + until?: number; + limit?: number; +}; +export type WorkflowRunStatus = "queued" | "started" | "completed" | "failed" | "crashed" | "dropped" | "interrupted" | "killed"; +/** One row in the workflow_runs materialized table. */ +export type WorkflowRun = { + runId: string; + workflow: string; + status: WorkflowRunStatus; + timestamp: number; + exitCode: number | null; +}; +/** One role-produced workflow-message row with 1-based round index (ROW_NUMBER over role messages only). */ +export type ThreadRoundRow = { + round: number; + logId: number; + timestamp: number; + message: { + role: string; + content: string; + meta: unknown; + timestamp: number; + }; +}; +/** Parameters for {@link LogStore.getThreadRounds}. */ +export type GetThreadRoundsParams = { + /** + * Exclusive upper bound on round index (1-based among role events). + * Use `0` to include all rounds (subject to `limit`). + */ + before: number; + /** Maximum rows returned from the DB (DESC by round). */ + limit: number; +}; +export type LogStore = { + append: (entry: Omit) => LogEntry; + query: (filter?: LogQuery) => LogEntry[]; + getMeta: (key: string) => string | null; + setMeta: (key: string, value: string) => void; + /** + * Append a workflow log event and atomically upsert the workflow_runs + * materialized table — both in a single SQLite transaction (RFC-002 §6.2). + */ + upsertWorkflowRun: (entry: Omit, run: WorkflowRun) => LogEntry; + /** + * Alias for upsertWorkflowRun — append a log entry and update workflow_runs + * in one atomic transaction. + */ + appendWithWorkflowUpdate: (entry: Omit, run: WorkflowRun) => LogEntry; + /** Get the current materialized state of a specific workflow run. */ + getWorkflowRun: (runId: string) => WorkflowRun | null; + /** + * Get all workflow runs with status 'queued' or 'started'. + * Optionally filter by workflow name. + */ + getActiveWorkflowRuns: (workflowName?: string) => WorkflowRun[]; + /** + * Get all workflow runs regardless of status, sorted by timestamp descending. + * Optionally filter by workflow name. + */ + getAllWorkflowRuns: (workflowName: string | null) => WorkflowRun[]; + /** + * Get the trigger payload for a workflow run (stored in the 'started' log entry). + * Returns null if not found. + */ + getTriggerPayload: (runId: string) => unknown; + /** + * Get all workflow CommandEvents for a specific run, ordered by id ASC. + * @deprecated Use getThreadMessages for the new WorkflowMessage format. + */ + getThreadEvents: (runId: string) => Array<{ + type: string; + [key: string]: unknown; + }>; + /** + * Get all WorkflowMessages for a specific run, ordered by id ASC. + * Used for crash recovery to rebuild the message chain. + */ + getThreadMessages: (runId: string) => Array<{ + role: string; + content: string; + meta: unknown; + timestamp: number; + }>; + /** + * Count role command events for a run (excludes `thread_start`/`__start__` messages and invalid payloads). + * Round indices for {@link getThreadRounds} are 1..count in chronological order. + */ + getThreadRoundCount: (runId: string) => number; + /** + * Role rounds for agent-oriented retrieval: each row is one `thread_command_event` or + * `thread_workflow_message` whose JSON `type` is not `thread_start` and `role` is not `__start__`, + * with `round` from ROW_NUMBER() OVER (ORDER BY id ASC). No schema migration — numbering is computed in SQL. + */ + getThreadRounds: (runId: string, params: GetThreadRoundsParams) => ThreadRoundRow[]; + /** + * The workflow `__start__` message for a run (if persisted), as a {@link ThreadRoundRow} + * with `round` 0 — not part of {@link getThreadRoundCount} / {@link getThreadRounds} numbering. + */ + getThreadStartMessage: (runId: string) => ThreadRoundRow | null; + /** + * Export logs older than the retention window to `data/archive/logs/YYYY-MM-DD.jsonl`, + * then delete those rows and advance `meta.archived_up_to` in one transaction per day + * (RFC-001 §5.4). + */ + archiveLogs: (options?: ArchiveLogsOptions) => ArchiveLogsResult; + close: () => void; +}; +export declare function createLogStore(dbPath: string): LogStore; +//# sourceMappingURL=log-store.d.ts.map \ No newline at end of file diff --git a/packages/store/src/log-store.d.ts.map b/packages/store/src/log-store.d.ts.map new file mode 100644 index 0000000..61de455 --- /dev/null +++ b/packages/store/src/log-store.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"log-store.d.ts","sourceRoot":"","sources":["log-store.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/workflow/package.json b/packages/workflow/package.json index c56a3e2..84fc580 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -8,7 +8,12 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" - } + }, + "./public-types": { + "types": "./dist/public-types.d.ts", + "default": "./dist/public-types.js" + }, + "./package.json": "./package.json" }, "files": ["dist"], "publishConfig": { @@ -16,7 +21,12 @@ }, "scripts": { "prepublishOnly": "bash ../../scripts/prepublish-check.sh", - "build": "rslib build" + "build": "rslib build && pnpm run build:public-types", + "build:public-types": "tsc -p tsconfig.public-types.json" + }, + "dependencies": { + "@uncaged/nerve-core": "workspace:*", + "@uncaged/nerve-store": "workspace:*" }, "devDependencies": { "@rslib/core": "^0.21.3", diff --git a/packages/workflow/rslib.config.ts b/packages/workflow/rslib.config.ts index 87bb8a3..6894607 100644 --- a/packages/workflow/rslib.config.ts +++ b/packages/workflow/rslib.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ source: { entry: { index: "src/index.ts", + worker: "src/worker.ts", }, }, output: { diff --git a/packages/workflow/src/experimental-warning-suppression.ts b/packages/workflow/src/experimental-warning-suppression.ts new file mode 100644 index 0000000..f33086d --- /dev/null +++ b/packages/workflow/src/experimental-warning-suppression.ts @@ -0,0 +1,23 @@ +/** + * Patches `process.emit` so `ExperimentalWarning` (e.g. from `node:sqlite`) is not + * forwarded to the default handler. Other warning types are unchanged. + * + * Import this module before any code that loads `node:sqlite`. + */ + +const WARNING_EVENT = "warning"; +const EXPERIMENTAL_WARNING_NAME = "ExperimentalWarning"; + +type EmitFn = typeof process.emit; + +const originalEmit = process.emit.bind(process) as EmitFn; + +process.emit = ((event: string | symbol, ...args: unknown[]): boolean => { + if (event === WARNING_EVENT) { + const w = args[0]; + if (w instanceof Error && w.name === EXPERIMENTAL_WARNING_NAME) { + return false; + } + } + return Reflect.apply(originalEmit, process, [event, ...args]) as boolean; +}) as EmitFn; diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index a2d0388..e2193fe 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -17,3 +17,37 @@ export type { } from "./types.js"; export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig } from "./config.js"; + +export type { + KillThreadMessage, + ResumeThreadMessage, + StartThreadMessage, + WorkflowParentToWorkerMessage, + WorkflowWorkerShutdownMessage, + ThreadEventType, + ThreadLifecycleEvent, + ThreadEventMessage, + WorkflowErrorMessage, + ThreadWorkflowMessage, + WorkflowWorkerToParentMessage, + WorkflowWorkerReadyMessage, + WorkflowChildToParentMessage, + WorkflowWorkerOutboundMessage, +} from "./ipc.js"; +export { + parseWorkflowParentMessage, + parseWorkflowWorkerToParentMessage, + parseWorkflowChildMessage, +} from "./ipc.js"; + +export { WORKFLOW_WORKER_PATH } from "./paths.js"; + +export { + createWorkerRuntime, + formatCapturedStderrTail, + formatChildExitSummary, +} from "./worker-runtime.js"; +export type { WorkerDrainOpts, WorkerRuntime, WorkerRuntimeConfig } from "./worker-runtime.js"; + +export { createWorkflowManager } from "./manager.js"; +export type { WorkflowLaunchParams, WorkflowManager } from "./manager.js"; diff --git a/packages/workflow/src/ipc.ts b/packages/workflow/src/ipc.ts new file mode 100644 index 0000000..94da576 --- /dev/null +++ b/packages/workflow/src/ipc.ts @@ -0,0 +1,314 @@ +/** + * IPC message types for parent (kernel) ↔ workflow worker communication. + * Protocol per RFC-002 §5.2. + */ + +import type { Result } from "@uncaged/nerve-core"; +import { err, isPlainRecord, ok } from "@uncaged/nerve-core"; + +/** Parent → Workflow Worker: start a new thread */ +export type StartThreadMessage = { + type: "start-thread"; + runId: string; + workflow: string; + prompt: string; + /** Safety-valve: max moderator rounds for this thread (engine launch parameter). */ + maxRounds: number; + /** When true, roles may skip side effects (thread-level hint on the start frame). */ + dryRun: boolean; +}; + +/** Parent → Workflow Worker: resume an existing thread after crash recovery */ +export type ResumeThreadMessage = { + type: "resume-thread"; + runId: string; + /** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */ + messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>; + /** Safety-valve: max moderator rounds for this thread. */ + maxRounds: number; + /** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */ + dryRun: boolean; +}; + +/** Parent → Workflow Worker: kill a specific running thread */ +export type KillThreadMessage = { + type: "kill-thread"; + runId: string; +}; + +/** Parent → Workflow Worker: graceful shutdown (same wire shape as sense worker). */ +export type WorkflowWorkerShutdownMessage = { + type: "shutdown"; +}; + +/** Messages the parent sends to a workflow worker process */ +export type WorkflowParentToWorkerMessage = + | StartThreadMessage + | ResumeThreadMessage + | KillThreadMessage + | WorkflowWorkerShutdownMessage; + +/** Valid lifecycle event types for a workflow thread. */ +export type ThreadEventType = + | "queued" + | "started" + | "step_complete" + | "completed" + | "failed" + | "killed"; + +/** Alias — lifecycle channel uses `ThreadEventType` values. */ +export type ThreadLifecycleEvent = ThreadEventType; + +/** + * Workflow Worker → Parent: a thread lifecycle event. + */ +export type ThreadEventMessage = { + type: "thread-event"; + runId: string; + eventType: ThreadEventType; + payload: unknown; +}; + +/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */ +export type WorkflowErrorMessage = { + type: "workflow-error"; + runId: string; + error: string; + /** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */ + exitCode: number; +}; + +/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */ +export type ThreadWorkflowMessage = { + type: "thread-workflow-message"; + runId: string; + /** The WorkflowMessage produced by the role — persisted for crash recovery. */ + message: { role: string; content: string; meta: unknown; timestamp: number }; +}; + +/** Messages a workflow worker sends to the parent (subset of full worker IPC). */ +export type WorkflowWorkerToParentMessage = + | ThreadEventMessage + | WorkflowErrorMessage + | ThreadWorkflowMessage; + +export type WorkflowWorkerReadyMessage = { type: "ready" }; + +/** Messages a workflow child may emit on IPC (including bootstrap ready). */ +export type WorkflowChildToParentMessage = + | WorkflowWorkerReadyMessage + | WorkflowWorkerToParentMessage; + +/** Messages a workflow worker process may send upstream (ready + persisted workflow IPC). */ +export type WorkflowWorkerOutboundMessage = + | WorkflowWorkerReadyMessage + | WorkflowWorkerToParentMessage; + +const WORKFLOW_PARENT_MSG_TYPES = new Set([ + "start-thread", + "resume-thread", + "kill-thread", + "shutdown", +]); + +function validateStartThreadMsg(obj: Record): string | 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 | null { + if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'"; + if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array"; + if (typeof obj.maxRounds !== "number") + return "'resume-thread' message missing number 'maxRounds'"; + if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'"; + return null; +} + +function parseParentStartThread(obj: Record): Result { + const errMsg = validateStartThreadMsg(obj); + if (errMsg !== null) return err(new Error(errMsg)); + return ok({ + type: "start-thread", + runId: obj.runId, + workflow: obj.workflow, + prompt: obj.prompt, + maxRounds: obj.maxRounds, + dryRun: obj.dryRun, + } as StartThreadMessage); +} + +function parseParentResumeThread(obj: Record): Result { + const errMsg = validateResumeThreadMsg(obj); + if (errMsg !== null) return err(new Error(errMsg)); + return ok({ + type: "resume-thread", + runId: obj.runId, + messages: obj.messages as ResumeThreadMessage["messages"], + maxRounds: obj.maxRounds, + dryRun: obj.dryRun, + } as ResumeThreadMessage); +} + +function parseParentKillThread(obj: Record): Result { + if (typeof obj.runId !== "string") { + return err(new Error("'kill-thread' message missing string 'runId'")); + } + return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage); +} + +/** Validate and parse an unknown IPC message for a workflow worker process. */ +export function parseWorkflowParentMessage(raw: unknown): Result { + if (!isPlainRecord(raw)) { + return err(new Error("IPC message is not an object")); + } + const obj = raw; + if (typeof obj.type !== "string") { + return err(new Error("IPC message missing string 'type' field")); + } + if (!WORKFLOW_PARENT_MSG_TYPES.has(obj.type)) { + return err(new Error(`Unknown workflow IPC message type: "${obj.type}"`)); + } + switch (obj.type) { + case "shutdown": + return ok({ type: "shutdown" }); + case "start-thread": + return parseParentStartThread(obj); + case "resume-thread": + return parseParentResumeThread(obj); + case "kill-thread": + return parseParentKillThread(obj); + default: + return err(new Error(`Unhandled workflow IPC message type: "${obj.type}"`)); + } +} + +function isThreadEventType(value: string): value is ThreadEventType { + switch (value) { + case "queued": + case "started": + case "step_complete": + case "completed": + case "failed": + case "killed": + return true; + default: + return false; + } +} + +function parseThreadEventMsg(obj: Record): Result { + 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): Result { + if (typeof obj.runId !== "string") { + return err(new Error("Worker 'workflow-error' message missing string 'runId' field")); + } + if (typeof obj.error !== "string") { + return err(new Error("Worker 'workflow-error' message missing string 'error' field")); + } + const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1; + return ok({ + type: "workflow-error", + runId: obj.runId, + error: obj.error, + exitCode, + }); +} + +function parseThreadWorkflowMessageMsg(obj: Record): Result { + if (typeof obj.runId !== "string") { + return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field")); + } + if (!isPlainRecord(obj.message)) { + return err(new Error("Worker 'thread-workflow-message' missing object 'message' field")); + } + const msg = obj.message; + if (typeof msg.role !== "string") { + return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field")); + } + if (typeof msg.content !== "string") { + return err( + new Error("Worker 'thread-workflow-message' message missing string 'content' field"), + ); + } + if (typeof msg.timestamp !== "number") { + return err( + new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"), + ); + } + return ok({ + type: "thread-workflow-message", + runId: obj.runId, + message: { + role: msg.role, + content: msg.content, + meta: "meta" in msg ? msg.meta : null, + timestamp: msg.timestamp, + }, + }); +} + +const WORKFLOW_WORKER_MSG_TYPES = new Set([ + "thread-event", + "workflow-error", + "thread-workflow-message", +]); + +/** Validate and parse workflow worker → parent IPC messages. */ +export function parseWorkflowWorkerToParentMessage( + raw: unknown, +): Result { + if (!isPlainRecord(raw)) { + return err(new Error("Worker IPC message is not an object")); + } + const obj = raw; + if (typeof obj.type !== "string") { + return err(new Error("Worker IPC message missing string 'type' field")); + } + if (!WORKFLOW_WORKER_MSG_TYPES.has(obj.type)) { + return err(new Error(`Unknown workflow worker IPC message type: "${obj.type}"`)); + } + if (obj.type === "thread-event") return parseThreadEventMsg(obj); + if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj); + return parseThreadWorkflowMessageMsg(obj); +} + +/** Parse messages from a workflow worker child (thread IPC + optional `ready`). */ +export function parseWorkflowChildMessage( + raw: unknown, +): Result { + if (!isPlainRecord(raw)) { + return err(new Error("Worker IPC message is not an object")); + } + const obj = raw; + if (typeof obj.type !== "string") { + return err(new Error("Worker IPC message missing string 'type' field")); + } + if (obj.type === "ready") { + return ok({ type: "ready" }); + } + return parseWorkflowWorkerToParentMessage(raw); +} diff --git a/packages/daemon/src/workflow-manager-support.ts b/packages/workflow/src/manager-support.ts similarity index 90% rename from packages/daemon/src/workflow-manager-support.ts rename to packages/workflow/src/manager-support.ts index 800030c..bde8ef7 100644 --- a/packages/daemon/src/workflow-manager-support.ts +++ b/packages/workflow/src/manager-support.ts @@ -2,15 +2,13 @@ * Pure helpers and IPC branching for workflow-manager (keeps workflow-manager.ts lean). */ -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import type { WorkflowMessage } from "@uncaged/nerve-core"; -import { START, isPlainRecord } from "@uncaged/nerve-core"; +import { isPlainRecord } from "@uncaged/nerve-core"; import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store"; import type { ResumeThreadMessage, ThreadEventMessage } from "./ipc.js"; -import type { WorkerToParentMessage } from "./ipc.js"; +import type { WorkflowChildToParentMessage } from "./ipc.js"; +import type { WorkflowMessage } from "./types.js"; +import { START } from "./types.js"; export type PendingThread = { runId: string; @@ -33,7 +31,7 @@ export const WORKFLOW_WORKER_RESPAWN = { } as const; /** - * Worker shutdown timeout — must stay in sync with SHUTDOWN_TIMEOUT_MS in workflow-worker.ts. + * Worker shutdown timeout — must stay in sync with shutdown handling in `worker.ts`. * The drain timeout passed to drainAndRespawn must be >= this value so the worker has * enough time to finish in-flight threads before the parent force-kills it. */ @@ -79,12 +77,6 @@ export function ensureThreadMessagesWithStart( return [start, ...mapped]; } -export function resolveWorkflowWorkerScript(): string { - const __filename = fileURLToPath(import.meta.url); - const __dir = dirname(__filename); - return join(__dir, "workflow-worker.js"); -} - export function mapWorkflowRunStatus(eventType: string): WorkflowRunStatus | null { const map: Record = { started: "started", @@ -223,9 +215,13 @@ export type WorkflowManagerMessageDeps = { export function dispatchWorkflowWorkerMessage( workflowName: string, - msg: WorkerToParentMessage, + msg: WorkflowChildToParentMessage, deps: WorkflowManagerMessageDeps, ): void { + if (msg.type === "ready") { + return; + } + if (msg.type === "thread-event") { deps.handleThreadEvent(workflowName, msg); return; @@ -247,10 +243,5 @@ export function dispatchWorkflowWorkerMessage( `[workflow-manager] workflow-error for runId "${msg.runId}" in "${workflowName}": ${msg.error}\n`, ); deps.onWorkflowRoleError(workflowName, msg.runId, msg.error, msg.exitCode); - return; - } - - if (msg.type === "error") { - process.stderr.write(`[workflow-manager] error from "${workflowName}" worker: ${msg.error}\n`); } } diff --git a/packages/daemon/src/workflow-manager.ts b/packages/workflow/src/manager.ts similarity index 98% rename from packages/daemon/src/workflow-manager.ts rename to packages/workflow/src/manager.ts index 9048097..1e55270 100644 --- a/packages/daemon/src/workflow-manager.ts +++ b/packages/workflow/src/manager.ts @@ -10,12 +10,13 @@ import type { NerveConfig, WorkflowConfig, WorkflowStatus } from "@uncaged/nerve import type { LogStore } from "@uncaged/nerve-store"; import type { KillThreadMessage, StartThreadMessage, ThreadEventMessage } from "./ipc.js"; -import { parseWorkerMessage } from "./ipc.js"; +import { parseWorkflowChildMessage } from "./ipc.js"; import { createWorkerRuntime, formatCapturedStderrTail, formatChildExitSummary, } from "./worker-runtime.js"; +import { WORKFLOW_WORKER_PATH } from "./paths.js"; import { DEFAULT_MAX_QUEUE, WORKER_SHUTDOWN_TIMEOUT_MS, @@ -25,8 +26,7 @@ import { dispatchWorkflowWorkerMessage, extractExitCodeFromPayload, recoverThreadsFromStore, - resolveWorkflowWorkerScript, -} from "./workflow-manager-support.js"; +} from "./manager-support.js"; export type WorkflowLaunchParams = { prompt: string; @@ -73,7 +73,7 @@ export function createWorkflowManager( initialConfig: NerveConfig, logStore: LogStore, ): WorkflowManager { - const workerScript = resolveWorkflowWorkerScript(); + const workerScript = WORKFLOW_WORKER_PATH; /** * Default drain timeout must be at least WORKER_SHUTDOWN_TIMEOUT_MS so the worker @@ -309,7 +309,7 @@ export function createWorkflowManager( } function handleWorkerMessage(workflowName: string, raw: unknown): void { - const result = parseWorkerMessage(raw); + const result = parseWorkflowChildMessage(raw); if (!result.ok) { process.stderr.write( `[workflow-manager] invalid message from "${workflowName}" worker: ${result.error.message}\n`, diff --git a/packages/workflow/src/paths.ts b/packages/workflow/src/paths.ts new file mode 100644 index 0000000..4bc2a95 --- /dev/null +++ b/packages/workflow/src/paths.ts @@ -0,0 +1,5 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Resolved path to the workflow child-process entry (`worker.js` adjacent to this module in dist). */ +export const WORKFLOW_WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.js"); diff --git a/packages/workflow/src/public-types.ts b/packages/workflow/src/public-types.ts new file mode 100644 index 0000000..0e39bff --- /dev/null +++ b/packages/workflow/src/public-types.ts @@ -0,0 +1,22 @@ +/** + * Narrow surface for `@uncaged/nerve-core` — workflow automaton types + config only. + * Keeps core's declaration graph free of IPC / manager / store. + */ + +export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js"; +export type { + WorkflowMessage, + RoleResult, + Role, + RoleMeta, + StartStep, + ThreadContext, + WorkflowContext, + AgentFn, + RoleStep, + ModeratorContext, + Moderator, + WorkflowDefinition, +} from "./types.js"; + +export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig } from "./config.js"; diff --git a/packages/daemon/src/worker-runtime.ts b/packages/workflow/src/worker-runtime.ts similarity index 100% rename from packages/daemon/src/worker-runtime.ts rename to packages/workflow/src/worker-runtime.ts diff --git a/packages/workflow/src/worker-signals.ts b/packages/workflow/src/worker-signals.ts new file mode 100644 index 0000000..b9b20ef --- /dev/null +++ b/packages/workflow/src/worker-signals.ts @@ -0,0 +1,17 @@ +/** + * Worker-process signal handling (fork IPC children only). + * Worker entrypoints import this module — not worker-runtime.ts (parent/kernel code). + */ + +/** + * Forked workers inherit the parent's process group. In foreground `nerve dev`, + * terminal-driven SIGINT/SIGTERM is delivered to the whole group, so workers can exit + * on the default handler before the kernel sends `{ type: "shutdown" }` over IPC. + * Swallow these in worker processes so the parent coordinates shutdown (issue #55). + * Only call when `process.send` is defined (fork IPC); standalone `node …-worker.js` keeps default Ctrl+C behaviour. + */ +export function ignoreSessionBroadcastSignals(): void { + const swallow = (): void => {}; + process.on("SIGINT", swallow); + process.on("SIGTERM", swallow); +} diff --git a/packages/daemon/src/workflow-worker.ts b/packages/workflow/src/worker.ts similarity index 97% rename from packages/daemon/src/workflow-worker.ts rename to packages/workflow/src/worker.ts index 29c36e5..c2ab882 100644 --- a/packages/daemon/src/workflow-worker.ts +++ b/packages/workflow/src/worker.ts @@ -14,6 +14,14 @@ import "./experimental-warning-suppression.js"; import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; +import { isPlainRecord } from "@uncaged/nerve-core"; + +import type { + ThreadEventType, + ThreadWorkflowMessage, + WorkflowWorkerOutboundMessage, +} from "./ipc.js"; +import { parseWorkflowParentMessage } from "./ipc.js"; import type { RoleMeta, RoleStep, @@ -21,22 +29,15 @@ import type { ThreadContext, WorkflowDefinition, WorkflowMessage, -} from "@uncaged/nerve-core"; -import { END, START, isPlainRecord } from "@uncaged/nerve-core"; - -import type { - ThreadEventType, - ThreadWorkflowMessageMessage, - WorkerToParentMessage, -} from "./ipc.js"; -import { parseParentMessage } from "./ipc.js"; +} from "./types.js"; +import { END, START } from "./types.js"; import { ignoreSessionBroadcastSignals } from "./worker-signals.js"; // --------------------------------------------------------------------------- // IPC helpers // --------------------------------------------------------------------------- -function send(msg: WorkerToParentMessage): void { +function send(msg: WorkflowWorkerOutboundMessage): void { if (process.send) { process.send(msg); } @@ -55,7 +56,7 @@ function sendWorkflowError(runId: string, error: string, exitCode = 1): void { } function sendWorkflowMessage(runId: string, message: WorkflowMessage): void { - const msg: ThreadWorkflowMessageMessage = { + const msg: ThreadWorkflowMessage = { type: "thread-workflow-message", runId, message: { @@ -334,7 +335,7 @@ function handleMessage( killFlags: Map, shuttingDown: { value: boolean }, ): void { - const parseResult = parseParentMessage(raw); + const parseResult = parseWorkflowParentMessage(raw); if (!parseResult.ok) { process.stderr.write(`[workflow-worker] Invalid IPC message: ${parseResult.error.message}\n`); return; diff --git a/packages/workflow/tsconfig.public-types.json b/packages/workflow/tsconfig.public-types.json new file mode 100644 index 0000000..6972ed5 --- /dev/null +++ b/packages/workflow/tsconfig.public-types.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "composite": false, + "noEmit": false + }, + "include": ["src/types.ts", "src/config.ts", "src/public-types.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e29a24..1032bbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@uncaged/nerve-daemon': specifier: workspace:* version: link:../daemon + '@uncaged/workflow': + specifier: workspace:* + version: link:../workflow vitest: specifier: ^4.1.5 version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3)) @@ -111,6 +114,9 @@ importers: '@uncaged/nerve-store': specifier: workspace:* version: link:../store + '@uncaged/workflow': + specifier: workspace:* + version: link:../workflow yaml: specifier: ^2.8.3 version: 2.8.3 @@ -219,6 +225,13 @@ importers: version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3)) packages/workflow: + dependencies: + '@uncaged/nerve-core': + specifier: workspace:* + version: link:../core + '@uncaged/nerve-store': + specifier: workspace:* + version: link:../store devDependencies: '@rslib/core': specifier: ^0.21.3