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
This commit is contained in:
2026-05-05 10:41:59 +00:00
parent 591be21bb0
commit cee65bbd87
52 changed files with 1072 additions and 278 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Always use static top-level `import` statements.
## Exceptions (must include a comment explaining why)
1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime
2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
2. **`packages/workflow/src/worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
When suppressing, add a comment directly above:
+1 -1
View File
@@ -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 .",
+2 -1
View File
@@ -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"
}
}
+3 -2
View File
@@ -51,8 +51,9 @@ import { workflowCommand } from "../commands/workflow.js";
const require = createRequire(import.meta.url);
const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
const nerveWorkflowRoot = dirname(require.resolve("@uncaged/workflow/package.json"));
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
const workflowWorkerScript = join(nerveWorkflowRoot, "dist", "worker.js");
const nerveYamlTemplate = `senses:
counter:
@@ -274,7 +275,7 @@ export async function startTestDaemon(
}
if (!existsSync(workflowWorkerScript)) {
throw new Error(
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\`.`,
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/workflow build\`.`,
);
}
+17
View File
@@ -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<T> = {
readonly witness: T | null;
};
export type ExtractFn<T> = (raw: string, schema: Schema<T>) => Promise<T>;
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
+1
View File
@@ -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"}
+63
View File
@@ -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>`. */
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<string, SenseConfig>;
workflows: Record<string, WorkflowConfig>;
api: NerveApiConfig;
/** Global extract defaults; `null` when the section is omitted. */
extract: ExtractConfig | null;
};
export type KnowledgeConfig = {
include: ReadonlyArray<string>;
exclude: ReadonlyArray<string>;
};
export declare function parseNerveConfig(raw: string): Result<NerveConfig>;
/**
* 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<KnowledgeConfig>;
//# sourceMappingURL=config.d.ts.map
+1
View File
@@ -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"}
+1 -1
View File
@@ -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 };
+120
View File
@@ -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<HealthInfo>;
listSenses(): Promise<SenseInfo[]>;
listWorkflows(): Promise<WorkflowStatus[]>;
triggerSense(name: string): Promise<DaemonTransportTriggerResult>;
/** When `launch` is null, implementations use engine defaults (empty prompt, default max rounds, dryRun false). */
triggerWorkflow(name: string, launch: DaemonTransportWorkflowLaunch | null): Promise<DaemonTransportTriggerResult>;
/** Kill a running or queued workflow thread by `runId` (same field as IPC `kill-workflow`). */
killWorkflow(runId: string): Promise<DaemonTransportTriggerResult>;
};
/**
* 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
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["daemon.ts"],"names":[],"mappings":"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"}
+21
View File
@@ -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
+1
View File
@@ -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"}
+2 -2
View File
@@ -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";
+39
View File
@@ -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<string>;
};
/**
* 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<S = unknown> = (state: S) => Promise<{
state: S;
trigger: SenseTrigger | null;
}>;
/**
* The full shape a sense module (`src/index.ts`) must export.
*/
export type SenseModule<S = unknown> = {
compute: SenseComputeFn<S>;
initialState: S;
};
/** Human-readable label for a sense schedule (`interval` and/or `on`). */
export declare function labelSenseTrigger(slice: Pick<SenseConfig, "interval" | "on">): 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, SenseConfig>): string[];
/** Validates `{ command: string }` from Sense compute or IPC (`trigger` field). */
export declare function parseSenseTrigger(value: unknown): Result<SenseTrigger>;
//# sourceMappingURL=sense.d.ts.map
+1
View File
@@ -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"}
+66
View File
@@ -0,0 +1,66 @@
export type Result<T, E = Error> = {
ok: true;
value: T;
} | {
ok: false;
error: E;
};
/** Compatible with `process.env` for `child_process.spawn`. */
export type SpawnEnv = Record<string, string | undefined>;
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<SpawnSafeOptions, "dryRun">;
export declare function ok<T>(value: T): Result<T, never>;
export declare function err<E = Error>(error: E): Result<never, E>;
/**
* 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<string, unknown>;
/**
* 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<number>;
/**
* 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<string>, options: SpawnSafeOptionsInput): Promise<Result<SpawnResult, SpawnError>>;
export {};
//# sourceMappingURL=util.d.ts.map
+1
View File
@@ -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"}
+2 -1
View File
@@ -22,10 +22,11 @@
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build",
"pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*",
"yaml": "^2.8.3"
-1
View File
@@ -11,7 +11,6 @@ export default defineConfig({
entry: {
index: "src/index.ts",
"sense-worker": "src/sense-worker.ts",
"workflow-worker": "src/workflow-worker.ts",
"experimental-warning-suppression": "src/experimental-warning-suppression.ts",
},
},
@@ -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<string, WorkflowConfig> = {}): NerveConfig {
return {
@@ -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<string, WorkflowConfig> = {}): NerveConfig {
@@ -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");
@@ -61,7 +61,7 @@ vi.mock("node:child_process", () => ({
}));
// Import after mock is set up
const { createWorkflowManager } = await import("../workflow-manager.js");
const { createWorkflowManager } = await import("@uncaged/workflow");
// ---------------------------------------------------------------------------
// Helpers
+1 -1
View File
@@ -1,6 +1,6 @@
import type { HealthInfo, SenseInfo, WorkflowStatus } from "@uncaged/nerve-core";
import type { WorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "@uncaged/workflow";
export type DaemonHandlerBundle = {
health: () => HealthInfo;
+3 -3
View File
@@ -12,7 +12,7 @@ export type {
ResumeThreadMessage,
ThreadEventMessage,
WorkflowErrorMessage,
ThreadWorkflowMessageMessage,
ThreadWorkflowMessage,
} from "./ipc.js";
export { loadSenseModule, executeCompute, readState, writeState } from "./sense-runtime.js";
@@ -45,5 +45,5 @@ export type {
WorkflowRunStatus,
} from "@uncaged/nerve-store";
export { createWorkflowManager } from "./workflow-manager.js";
export type { WorkflowManager } from "./workflow-manager.js";
export { createWorkflowManager } from "@uncaged/workflow";
export type { WorkflowManager } from "@uncaged/workflow";
+40 -218
View File
@@ -1,10 +1,30 @@
/**
* IPC message types for parent (kernel) ↔ sense worker communication.
* Protocol per RFC §5.2: hub-and-spoke, all messages through engine.
*
* Workflow worker IPC types and parsers live in `@uncaged/workflow`.
*/
import type { Result, SenseTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok, parseSenseTrigger } from "@uncaged/nerve-core";
import type {
KillThreadMessage,
ResumeThreadMessage,
StartThreadMessage,
ThreadEventMessage,
ThreadWorkflowMessage,
WorkflowErrorMessage,
} from "@uncaged/workflow";
import { parseWorkflowParentMessage, parseWorkflowWorkerToParentMessage } from "@uncaged/workflow";
export type {
KillThreadMessage,
ResumeThreadMessage,
StartThreadMessage,
ThreadEventMessage,
ThreadWorkflowMessage,
WorkflowErrorMessage,
} from "@uncaged/workflow";
/** Parent → Worker: trigger one compute cycle for a sense */
export type ComputeMessage = {
@@ -22,40 +42,6 @@ export type HealthRequestMessage = {
type: "health-request";
};
// ---------------------------------------------------------------------------
// Workflow IPC messages (RFC-002 §5.2)
// ---------------------------------------------------------------------------
/** Parent → Workflow Worker: start a new thread */
export type StartThreadMessage = {
type: "start-thread";
runId: string;
workflow: string;
prompt: string;
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
maxRounds: number;
/** When true, roles may skip side effects (thread-level hint on the start frame). */
dryRun: boolean;
};
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
export type ResumeThreadMessage = {
type: "resume-thread";
runId: string;
/** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
/** Safety-valve: max moderator rounds for this thread. */
maxRounds: number;
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
dryRun: boolean;
};
/** Parent → Workflow Worker: kill a specific running thread */
export type KillThreadMessage = {
type: "kill-thread";
runId: string;
};
/** Union of all messages the parent sends to a worker */
export type ParentToWorkerMessage =
| ComputeMessage
@@ -92,46 +78,6 @@ export type HealthResponseMessage = {
inFlightCount: number;
};
// ---------------------------------------------------------------------------
// Workflow Worker → Parent messages (RFC-002 §5.2)
// ---------------------------------------------------------------------------
/** Valid lifecycle event types for a workflow thread. */
export type ThreadEventType =
| "queued"
| "started"
| "step_complete"
| "completed"
| "failed"
| "killed";
/**
* Workflow Worker → Parent: a thread lifecycle event.
*/
export type ThreadEventMessage = {
type: "thread-event";
runId: string;
eventType: ThreadEventType;
payload: unknown;
};
/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */
export type WorkflowErrorMessage = {
type: "workflow-error";
runId: string;
error: string;
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
exitCode: number;
};
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
export type ThreadWorkflowMessageMessage = {
type: "thread-workflow-message";
runId: string;
/** The WorkflowMessage produced by the role — persisted for crash recovery. */
message: { role: string; content: string; meta: unknown; timestamp: number };
};
/** Union of all messages a worker sends to the parent */
export type WorkerToParentMessage =
| ComputeResultMessage
@@ -140,7 +86,7 @@ export type WorkerToParentMessage =
| HealthResponseMessage
| ThreadEventMessage
| WorkflowErrorMessage
| ThreadWorkflowMessageMessage;
| ThreadWorkflowMessage;
const PARENT_MSG_TYPES = new Set([
"compute",
@@ -151,24 +97,6 @@ const PARENT_MSG_TYPES = new Set([
"kill-thread",
]);
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'start-thread' message missing string 'runId'";
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
return null;
}
function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'";
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
if (typeof obj.maxRounds !== "number")
return "'resume-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
return null;
}
function parseParentCompute(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
if (typeof obj.sense !== "string") {
return err(new Error("IPC 'compute' message missing string 'sense' field"));
@@ -176,40 +104,6 @@ function parseParentCompute(obj: Record<string, unknown>): Result<ParentToWorker
return ok({ type: "compute", sense: obj.sense });
}
function parseParentStartThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
const errMsg = validateStartThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
// Field types are validated above; `Record<string, unknown>` values stay `unknown` to TypeScript.
return ok({
type: "start-thread",
runId: obj.runId,
workflow: obj.workflow,
prompt: obj.prompt,
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as StartThreadMessage);
}
function parseParentResumeThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
const errMsg = validateResumeThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
// Elements are validated as plain objects by the kernel; trust the wire shape here.
return ok({
type: "resume-thread",
runId: obj.runId,
messages: obj.messages as ResumeThreadMessage["messages"],
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as ResumeThreadMessage);
}
function parseParentKillThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("'kill-thread' message missing string 'runId'"));
}
return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage);
}
/** Validate and parse an unknown IPC message received from the parent process. */
export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage> {
if (!isPlainRecord(raw)) {
@@ -230,11 +124,14 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
case "health-request":
return ok({ type: "health-request" });
case "start-thread":
return parseParentStartThread(obj);
case "resume-thread":
return parseParentResumeThread(obj);
case "kill-thread":
return parseParentKillThread(obj);
case "kill-thread": {
const wf = parseWorkflowParentMessage(raw);
if (!wf.ok) {
return wf;
}
return ok(wf.value as ParentToWorkerMessage);
}
default:
return err(new Error(`Unhandled IPC message type: "${obj.type}"`));
}
@@ -299,55 +196,11 @@ function parseHealthResponseMsg(obj: Record<string, unknown>): Result<WorkerToPa
});
}
function isThreadEventType(value: string): value is ThreadEventType {
switch (value) {
case "queued":
case "started":
case "step_complete":
case "completed":
case "failed":
case "killed":
return true;
default:
return false;
}
}
function parseThreadEventMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
}
if (typeof obj.eventType !== "string" || !isThreadEventType(obj.eventType)) {
return err(
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
);
}
if (!("payload" in obj)) {
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
}
return ok({
type: "thread-event",
runId: obj.runId,
eventType: obj.eventType,
payload: obj.payload,
});
}
function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
}
if (typeof obj.error !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
}
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
return ok({
type: "workflow-error",
runId: obj.runId,
error: obj.error,
exitCode,
});
}
const WORKFLOW_WORKER_MSG_TYPES = new Set([
"thread-event",
"workflow-error",
"thread-workflow-message",
]);
const WORKER_MSG_TYPES = new Set([
"compute-result",
@@ -359,41 +212,6 @@ const WORKER_MSG_TYPES = new Set([
"thread-workflow-message",
]);
function parseThreadWorkflowMessageMsg(
obj: Record<string, unknown>,
): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
}
if (!isPlainRecord(obj.message)) {
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
}
const msg = obj.message;
if (typeof msg.role !== "string") {
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
}
if (typeof msg.content !== "string") {
return err(
new Error("Worker 'thread-workflow-message' message missing string 'content' field"),
);
}
if (typeof msg.timestamp !== "number") {
return err(
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
);
}
return ok({
type: "thread-workflow-message",
runId: obj.runId,
message: {
role: msg.role,
content: msg.content,
meta: "meta" in msg ? msg.meta : undefined,
timestamp: msg.timestamp,
},
});
}
/** Validate and parse an unknown IPC message received from a worker process. */
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
if (!isPlainRecord(raw)) {
@@ -406,11 +224,15 @@ export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage>
if (!WORKER_MSG_TYPES.has(obj.type)) {
return err(new Error(`Unknown worker IPC message type: "${obj.type}"`));
}
if (WORKFLOW_WORKER_MSG_TYPES.has(obj.type)) {
const wf = parseWorkflowWorkerToParentMessage(raw);
if (!wf.ok) {
return wf;
}
return ok(wf.value as WorkerToParentMessage);
}
if (obj.type === "compute-result") return parseComputeResultMsg(obj);
if (obj.type === "error") return parseErrorMsg(obj);
if (obj.type === "health-response") return parseHealthResponseMsg(obj);
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj);
return ok({ type: "ready" });
}
+1 -1
View File
@@ -9,7 +9,7 @@ import type { NerveConfig } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { LogStore } from "@uncaged/nerve-store";
import type { WorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "@uncaged/workflow";
export type KernelFileWatchDeps = {
nerveRoot: string;
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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);
+17
View File
@@ -0,0 +1,17 @@
/**
* CAS blob store — sha256 content-addressable files under `data/blobs/`.
*
* Layout: `<root>/<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
+1
View File
@@ -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"}
+8
View File
@@ -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
+1
View File
@@ -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"}
+32
View File
@@ -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
+1
View File
@@ -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"}
+134
View File
@@ -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, "id">) => 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<LogEntry, "id">, run: WorkflowRun) => LogEntry;
/**
* Alias for upsertWorkflowRun — append a log entry and update workflow_runs
* in one atomic transaction.
*/
appendWithWorkflowUpdate: (entry: Omit<LogEntry, "id">, 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
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"log-store.d.ts","sourceRoot":"","sources":["log-store.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAmBH,OAAO,KAAK,EAAwB,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAEpG,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,YAAY,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAEpG,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAMF,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,SAAS,GACT,WAAW,GACX,QAAQ,GACR,SAAS,GACT,SAAS,GACT,aAAa,GACb,QAAQ,CAAC;AAwBb,uDAAuD;AACvD,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,CAAC;AAEF,4GAA4G;AAC5G,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9E,CAAC;AAEF,uDAAuD;AACvD,MAAM,MAAM,qBAAqB,GAAG;IAClC;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,MAAM,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,KAAK,QAAQ,CAAC;IAClD,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,QAAQ,KAAK,QAAQ,EAAE,CAAC;IACzC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IACxC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C;;;OAGG;IACH,iBAAiB,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,WAAW,KAAK,QAAQ,CAAC;IAC/E;;;OAGG;IACH,wBAAwB,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,WAAW,KAAK,QAAQ,CAAC;IACtF,qEAAqE;IACrE,cAAc,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,WAAW,GAAG,IAAI,CAAC;IACtD;;;OAGG;IACH,qBAAqB,EAAE,CAAC,YAAY,CAAC,EAAE,MAAM,KAAK,WAAW,EAAE,CAAC;IAChE;;;OAGG;IACH,kBAAkB,EAAE,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,KAAK,WAAW,EAAE,CAAC;IACnE;;;OAGG;IACH,iBAAiB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IAC9C;;;OAGG;IACH,eAAe,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC,CAAC;IACpF;;;OAGG;IACH,iBAAiB,EAAE,CACjB,KAAK,EAAE,MAAM,KACV,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChF;;;OAGG;IACH,mBAAmB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C;;;;OAIG;IACH,eAAe,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,qBAAqB,KAAK,cAAc,EAAE,CAAC;IACpF;;;OAGG;IACH,qBAAqB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,cAAc,GAAG,IAAI,CAAC;IAChE;;;;OAIG;IACH,WAAW,EAAE,CAAC,OAAO,CAAC,EAAE,kBAAkB,KAAK,iBAAiB,CAAC;IACjE,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB,CAAC;AA4JF,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAqbvD"}
+12 -2
View File
@@ -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",
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
source: {
entry: {
index: "src/index.ts",
worker: "src/worker.ts",
},
},
output: {
@@ -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;
+34
View File
@@ -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";
+314
View File
@@ -0,0 +1,314 @@
/**
* IPC message types for parent (kernel) ↔ workflow worker communication.
* Protocol per RFC-002 §5.2.
*/
import type { Result } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
/** Parent → Workflow Worker: start a new thread */
export type StartThreadMessage = {
type: "start-thread";
runId: string;
workflow: string;
prompt: string;
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
maxRounds: number;
/** When true, roles may skip side effects (thread-level hint on the start frame). */
dryRun: boolean;
};
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
export type ResumeThreadMessage = {
type: "resume-thread";
runId: string;
/** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
/** Safety-valve: max moderator rounds for this thread. */
maxRounds: number;
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
dryRun: boolean;
};
/** Parent → Workflow Worker: kill a specific running thread */
export type KillThreadMessage = {
type: "kill-thread";
runId: string;
};
/** Parent → Workflow Worker: graceful shutdown (same wire shape as sense worker). */
export type WorkflowWorkerShutdownMessage = {
type: "shutdown";
};
/** Messages the parent sends to a workflow worker process */
export type WorkflowParentToWorkerMessage =
| StartThreadMessage
| ResumeThreadMessage
| KillThreadMessage
| WorkflowWorkerShutdownMessage;
/** Valid lifecycle event types for a workflow thread. */
export type ThreadEventType =
| "queued"
| "started"
| "step_complete"
| "completed"
| "failed"
| "killed";
/** Alias — lifecycle channel uses `ThreadEventType` values. */
export type ThreadLifecycleEvent = ThreadEventType;
/**
* Workflow Worker → Parent: a thread lifecycle event.
*/
export type ThreadEventMessage = {
type: "thread-event";
runId: string;
eventType: ThreadEventType;
payload: unknown;
};
/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */
export type WorkflowErrorMessage = {
type: "workflow-error";
runId: string;
error: string;
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
exitCode: number;
};
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
export type ThreadWorkflowMessage = {
type: "thread-workflow-message";
runId: string;
/** The WorkflowMessage produced by the role — persisted for crash recovery. */
message: { role: string; content: string; meta: unknown; timestamp: number };
};
/** Messages a workflow worker sends to the parent (subset of full worker IPC). */
export type WorkflowWorkerToParentMessage =
| ThreadEventMessage
| WorkflowErrorMessage
| ThreadWorkflowMessage;
export type WorkflowWorkerReadyMessage = { type: "ready" };
/** Messages a workflow child may emit on IPC (including bootstrap ready). */
export type WorkflowChildToParentMessage =
| WorkflowWorkerReadyMessage
| WorkflowWorkerToParentMessage;
/** Messages a workflow worker process may send upstream (ready + persisted workflow IPC). */
export type WorkflowWorkerOutboundMessage =
| WorkflowWorkerReadyMessage
| WorkflowWorkerToParentMessage;
const WORKFLOW_PARENT_MSG_TYPES = new Set([
"start-thread",
"resume-thread",
"kill-thread",
"shutdown",
]);
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'start-thread' message missing string 'runId'";
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
return null;
}
function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'";
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
if (typeof obj.maxRounds !== "number")
return "'resume-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
return null;
}
function parseParentStartThread(obj: Record<string, unknown>): Result<StartThreadMessage> {
const errMsg = validateStartThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
return ok({
type: "start-thread",
runId: obj.runId,
workflow: obj.workflow,
prompt: obj.prompt,
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as StartThreadMessage);
}
function parseParentResumeThread(obj: Record<string, unknown>): Result<ResumeThreadMessage> {
const errMsg = validateResumeThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
return ok({
type: "resume-thread",
runId: obj.runId,
messages: obj.messages as ResumeThreadMessage["messages"],
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as ResumeThreadMessage);
}
function parseParentKillThread(obj: Record<string, unknown>): Result<KillThreadMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("'kill-thread' message missing string 'runId'"));
}
return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage);
}
/** Validate and parse an unknown IPC message for a workflow worker process. */
export function parseWorkflowParentMessage(raw: unknown): Result<WorkflowParentToWorkerMessage> {
if (!isPlainRecord(raw)) {
return err(new Error("IPC message is not an object"));
}
const obj = raw;
if (typeof obj.type !== "string") {
return err(new Error("IPC message missing string 'type' field"));
}
if (!WORKFLOW_PARENT_MSG_TYPES.has(obj.type)) {
return err(new Error(`Unknown workflow IPC message type: "${obj.type}"`));
}
switch (obj.type) {
case "shutdown":
return ok({ type: "shutdown" });
case "start-thread":
return parseParentStartThread(obj);
case "resume-thread":
return parseParentResumeThread(obj);
case "kill-thread":
return parseParentKillThread(obj);
default:
return err(new Error(`Unhandled workflow IPC message type: "${obj.type}"`));
}
}
function isThreadEventType(value: string): value is ThreadEventType {
switch (value) {
case "queued":
case "started":
case "step_complete":
case "completed":
case "failed":
case "killed":
return true;
default:
return false;
}
}
function parseThreadEventMsg(obj: Record<string, unknown>): Result<ThreadEventMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
}
if (typeof obj.eventType !== "string" || !isThreadEventType(obj.eventType)) {
return err(
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
);
}
if (!("payload" in obj)) {
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
}
return ok({
type: "thread-event",
runId: obj.runId,
eventType: obj.eventType,
payload: obj.payload,
});
}
function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkflowErrorMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
}
if (typeof obj.error !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
}
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
return ok({
type: "workflow-error",
runId: obj.runId,
error: obj.error,
exitCode,
});
}
function parseThreadWorkflowMessageMsg(obj: Record<string, unknown>): Result<ThreadWorkflowMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
}
if (!isPlainRecord(obj.message)) {
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
}
const msg = obj.message;
if (typeof msg.role !== "string") {
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
}
if (typeof msg.content !== "string") {
return err(
new Error("Worker 'thread-workflow-message' message missing string 'content' field"),
);
}
if (typeof msg.timestamp !== "number") {
return err(
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
);
}
return ok({
type: "thread-workflow-message",
runId: obj.runId,
message: {
role: msg.role,
content: msg.content,
meta: "meta" in msg ? msg.meta : null,
timestamp: msg.timestamp,
},
});
}
const WORKFLOW_WORKER_MSG_TYPES = new Set([
"thread-event",
"workflow-error",
"thread-workflow-message",
]);
/** Validate and parse workflow worker → parent IPC messages. */
export function parseWorkflowWorkerToParentMessage(
raw: unknown,
): Result<WorkflowWorkerToParentMessage> {
if (!isPlainRecord(raw)) {
return err(new Error("Worker IPC message is not an object"));
}
const obj = raw;
if (typeof obj.type !== "string") {
return err(new Error("Worker IPC message missing string 'type' field"));
}
if (!WORKFLOW_WORKER_MSG_TYPES.has(obj.type)) {
return err(new Error(`Unknown workflow worker IPC message type: "${obj.type}"`));
}
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
return parseThreadWorkflowMessageMsg(obj);
}
/** Parse messages from a workflow worker child (thread IPC + optional `ready`). */
export function parseWorkflowChildMessage(
raw: unknown,
): Result<WorkflowChildToParentMessage> {
if (!isPlainRecord(raw)) {
return err(new Error("Worker IPC message is not an object"));
}
const obj = raw;
if (typeof obj.type !== "string") {
return err(new Error("Worker IPC message missing string 'type' field"));
}
if (obj.type === "ready") {
return ok({ type: "ready" });
}
return parseWorkflowWorkerToParentMessage(raw);
}
@@ -2,15 +2,13 @@
* Pure helpers and IPC branching for workflow-manager (keeps workflow-manager.ts lean).
*/
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { WorkflowMessage } from "@uncaged/nerve-core";
import { START, isPlainRecord } from "@uncaged/nerve-core";
import { isPlainRecord } from "@uncaged/nerve-core";
import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store";
import type { ResumeThreadMessage, ThreadEventMessage } from "./ipc.js";
import type { WorkerToParentMessage } from "./ipc.js";
import type { WorkflowChildToParentMessage } from "./ipc.js";
import type { WorkflowMessage } from "./types.js";
import { START } from "./types.js";
export type PendingThread = {
runId: string;
@@ -33,7 +31,7 @@ export const WORKFLOW_WORKER_RESPAWN = {
} as const;
/**
* Worker shutdown timeout must stay in sync with SHUTDOWN_TIMEOUT_MS in workflow-worker.ts.
* Worker shutdown timeout must stay in sync with shutdown handling in `worker.ts`.
* The drain timeout passed to drainAndRespawn must be >= this value so the worker has
* enough time to finish in-flight threads before the parent force-kills it.
*/
@@ -79,12 +77,6 @@ export function ensureThreadMessagesWithStart(
return [start, ...mapped];
}
export function resolveWorkflowWorkerScript(): string {
const __filename = fileURLToPath(import.meta.url);
const __dir = dirname(__filename);
return join(__dir, "workflow-worker.js");
}
export function mapWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
const map: Record<string, WorkflowRunStatus> = {
started: "started",
@@ -223,9 +215,13 @@ export type WorkflowManagerMessageDeps = {
export function dispatchWorkflowWorkerMessage(
workflowName: string,
msg: WorkerToParentMessage,
msg: WorkflowChildToParentMessage,
deps: WorkflowManagerMessageDeps,
): void {
if (msg.type === "ready") {
return;
}
if (msg.type === "thread-event") {
deps.handleThreadEvent(workflowName, msg);
return;
@@ -247,10 +243,5 @@ export function dispatchWorkflowWorkerMessage(
`[workflow-manager] workflow-error for runId "${msg.runId}" in "${workflowName}": ${msg.error}\n`,
);
deps.onWorkflowRoleError(workflowName, msg.runId, msg.error, msg.exitCode);
return;
}
if (msg.type === "error") {
process.stderr.write(`[workflow-manager] error from "${workflowName}" worker: ${msg.error}\n`);
}
}
@@ -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`,
+5
View File
@@ -0,0 +1,5 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
/** Resolved path to the workflow child-process entry (`worker.js` adjacent to this module in dist). */
export const WORKFLOW_WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.js");
+22
View File
@@ -0,0 +1,22 @@
/**
* Narrow surface for `@uncaged/nerve-core` — workflow automaton types + config only.
* Keeps core's declaration graph free of IPC / manager / store.
*/
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./types.js";
export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig } from "./config.js";
+17
View File
@@ -0,0 +1,17 @@
/**
* Worker-process signal handling (fork IPC children only).
* Worker entrypoints import this module — not worker-runtime.ts (parent/kernel code).
*/
/**
* Forked workers inherit the parent's process group. In foreground `nerve dev`,
* terminal-driven SIGINT/SIGTERM is delivered to the whole group, so workers can exit
* on the default handler before the kernel sends `{ type: "shutdown" }` over IPC.
* Swallow these in worker processes so the parent coordinates shutdown (issue #55).
* Only call when `process.send` is defined (fork IPC); standalone `node …-worker.js` keeps default Ctrl+C behaviour.
*/
export function ignoreSessionBroadcastSignals(): void {
const swallow = (): void => {};
process.on("SIGINT", swallow);
process.on("SIGTERM", swallow);
}
@@ -14,6 +14,14 @@ import "./experimental-warning-suppression.js";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { isPlainRecord } from "@uncaged/nerve-core";
import type {
ThreadEventType,
ThreadWorkflowMessage,
WorkflowWorkerOutboundMessage,
} from "./ipc.js";
import { parseWorkflowParentMessage } from "./ipc.js";
import type {
RoleMeta,
RoleStep,
@@ -21,22 +29,15 @@ import type {
ThreadContext,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
import type {
ThreadEventType,
ThreadWorkflowMessageMessage,
WorkerToParentMessage,
} from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
} from "./types.js";
import { END, START } from "./types.js";
import { ignoreSessionBroadcastSignals } from "./worker-signals.js";
// ---------------------------------------------------------------------------
// IPC helpers
// ---------------------------------------------------------------------------
function send(msg: WorkerToParentMessage): void {
function send(msg: WorkflowWorkerOutboundMessage): void {
if (process.send) {
process.send(msg);
}
@@ -55,7 +56,7 @@ function sendWorkflowError(runId: string, error: string, exitCode = 1): void {
}
function sendWorkflowMessage(runId: string, message: WorkflowMessage): void {
const msg: ThreadWorkflowMessageMessage = {
const msg: ThreadWorkflowMessage = {
type: "thread-workflow-message",
runId,
message: {
@@ -334,7 +335,7 @@ function handleMessage(
killFlags: Map<string, KillFlag>,
shuttingDown: { value: boolean },
): void {
const parseResult = parseParentMessage(raw);
const parseResult = parseWorkflowParentMessage(raw);
if (!parseResult.ok) {
process.stderr.write(`[workflow-worker] Invalid IPC message: ${parseResult.error.message}\n`);
return;
@@ -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"]
}
+13
View File
@@ -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