@bratsos/workflow-engine-host-node 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @bratsos/workflow-engine-host-node
2
+
3
+ Node.js host for the [`@bratsos/workflow-engine`](../workflow-engine) command kernel. Provides process loops, signal handling, and continuous job processing.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @bratsos/workflow-engine-host-node
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { createKernel } from "@bratsos/workflow-engine/kernel";
15
+ import { createNodeHost } from "@bratsos/workflow-engine-host-node";
16
+ import { createPrismaJobQueue } from "@bratsos/workflow-engine";
17
+
18
+ const kernel = createKernel({ /* ... */ });
19
+ const jobTransport = createPrismaJobQueue(prisma);
20
+
21
+ const host = createNodeHost({
22
+ kernel,
23
+ jobTransport,
24
+ workerId: "worker-1",
25
+ });
26
+
27
+ await host.start();
28
+ ```
29
+
30
+ ## API
31
+
32
+ ### `createNodeHost(config): NodeHost`
33
+
34
+ Creates a new Node host instance.
35
+
36
+ ### `NodeHostConfig`
37
+
38
+ | Option | Type | Default | Description |
39
+ |--------|------|---------|-------------|
40
+ | `kernel` | `Kernel` | required | Kernel instance to dispatch commands to |
41
+ | `jobTransport` | `JobTransport` | required | Job transport for dequeue/complete/suspend/fail |
42
+ | `workerId` | `string` | required | Unique worker identifier |
43
+ | `orchestrationIntervalMs` | `number` | `10_000` | Interval for claim/poll/reap/flush orchestration tick |
44
+ | `jobPollIntervalMs` | `number` | `1_000` | Interval for polling job queue when empty |
45
+ | `staleLeaseThresholdMs` | `number` | `60_000` | Time before a job lease is considered stale |
46
+ | `maxClaimsPerTick` | `number` | `10` | Max pending runs to claim per orchestration tick |
47
+ | `maxSuspendedChecksPerTick` | `number` | `10` | Max suspended stages to poll per tick |
48
+ | `maxOutboxFlushPerTick` | `number` | `100` | Max outbox events to flush per tick |
49
+
50
+ ### `NodeHost`
51
+
52
+ | Method | Returns | Description |
53
+ |--------|---------|-------------|
54
+ | `start()` | `Promise<void>` | Start polling loops and register SIGTERM/SIGINT handlers |
55
+ | `stop()` | `Promise<void>` | Graceful shutdown -- clears timers and signal handlers |
56
+ | `getStats()` | `HostStats` | Runtime statistics |
57
+
58
+ ### `HostStats`
59
+
60
+ ```typescript
61
+ interface HostStats {
62
+ workerId: string;
63
+ jobsProcessed: number;
64
+ orchestrationTicks: number;
65
+ isRunning: boolean;
66
+ uptimeMs: number;
67
+ }
68
+ ```
69
+
70
+ ## How It Works
71
+
72
+ The host runs two concurrent loops:
73
+
74
+ 1. **Orchestration timer** (every `orchestrationIntervalMs`):
75
+ - `run.claimPending` -- claim pending runs, enqueue first-stage jobs
76
+ - `stage.pollSuspended` -- check if suspended stages are ready to resume
77
+ - `lease.reapStale` -- release stale job leases from crashed workers
78
+ - `outbox.flush` -- publish pending events through EventSink
79
+
80
+ 2. **Job processing loop** (continuous):
81
+ - Dequeue next job from `jobTransport`
82
+ - Dispatch `job.execute` to the kernel
83
+ - On completion: mark complete, dispatch `run.transition`
84
+ - On suspension: mark suspended with next poll time
85
+ - On failure: mark failed with retry flag
86
+ - Sleep `jobPollIntervalMs` when queue is empty
87
+
88
+ Signal handlers (`SIGTERM`, `SIGINT`) automatically call `stop()` for graceful shutdown.
89
+
90
+ ## Worker Process Pattern
91
+
92
+ ```typescript
93
+ // worker.ts
94
+ import { host } from "./setup";
95
+
96
+ await host.start();
97
+ // Host runs until SIGTERM/SIGINT or host.stop() is called
98
+ ```
99
+
100
+ ```bash
101
+ npx tsx worker.ts
102
+ ```
103
+
104
+ ## Multi-Worker
105
+
106
+ Multiple workers can share the same database. Each needs a unique `workerId`:
107
+
108
+ ```typescript
109
+ // worker-1
110
+ createNodeHost({ kernel, jobTransport, workerId: "worker-1" });
111
+
112
+ // worker-2
113
+ createNodeHost({ kernel, jobTransport, workerId: "worker-2" });
114
+ ```
115
+
116
+ Run claiming uses `FOR UPDATE SKIP LOCKED` in PostgreSQL to prevent race conditions.
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,48 @@
1
+ import { Kernel, JobTransport } from '@bratsos/workflow-engine/kernel';
2
+
3
+ /**
4
+ * Node Host for Workflow Engine Command Kernel
5
+ *
6
+ * Wraps the environment-agnostic kernel with Node.js process loops,
7
+ * signal handling, and job processing. The host dispatches kernel
8
+ * commands on intervals and manages the job dequeue/execute cycle.
9
+ *
10
+ * The kernel remains unaware of process state — all timers, signals,
11
+ * and loop pacing live here.
12
+ */
13
+
14
+ interface NodeHostConfig {
15
+ /** Kernel instance to dispatch commands to. */
16
+ kernel: Kernel;
17
+ /** Job transport for dequeue/complete/suspend/fail. */
18
+ jobTransport: JobTransport;
19
+ /** Unique worker identifier. */
20
+ workerId: string;
21
+ /** Orchestration poll interval in milliseconds (default: 10_000). */
22
+ orchestrationIntervalMs?: number;
23
+ /** Job dequeue poll interval when queue is empty (default: 1_000). */
24
+ jobPollIntervalMs?: number;
25
+ /** Stale lease threshold in milliseconds (default: 60_000). */
26
+ staleLeaseThresholdMs?: number;
27
+ /** Max pending runs to claim per orchestration tick (default: 10). */
28
+ maxClaimsPerTick?: number;
29
+ /** Max suspended stages to check per tick (default: 10). */
30
+ maxSuspendedChecksPerTick?: number;
31
+ /** Max outbox events to flush per tick (default: 100). */
32
+ maxOutboxFlushPerTick?: number;
33
+ }
34
+ interface HostStats {
35
+ workerId: string;
36
+ jobsProcessed: number;
37
+ orchestrationTicks: number;
38
+ isRunning: boolean;
39
+ uptimeMs: number;
40
+ }
41
+ interface NodeHost {
42
+ start(): Promise<void>;
43
+ stop(): Promise<void>;
44
+ getStats(): HostStats;
45
+ }
46
+ declare function createNodeHost(config: NodeHostConfig): NodeHost;
47
+
48
+ export { type HostStats, type NodeHost, type NodeHostConfig, createNodeHost };
package/dist/index.js ADDED
@@ -0,0 +1,162 @@
1
+ // src/host.ts
2
+ var NodeHostImpl = class {
3
+ running = false;
4
+ jobsProcessed = 0;
5
+ orchestrationTicks = 0;
6
+ startTime = 0;
7
+ orchestrationTimer = null;
8
+ signalHandlers = [];
9
+ kernel;
10
+ jobTransport;
11
+ workerId;
12
+ orchestrationIntervalMs;
13
+ jobPollIntervalMs;
14
+ staleLeaseThresholdMs;
15
+ maxClaimsPerTick;
16
+ maxSuspendedChecksPerTick;
17
+ maxOutboxFlushPerTick;
18
+ constructor(config) {
19
+ this.kernel = config.kernel;
20
+ this.jobTransport = config.jobTransport;
21
+ this.workerId = config.workerId;
22
+ this.orchestrationIntervalMs = config.orchestrationIntervalMs ?? 1e4;
23
+ this.jobPollIntervalMs = config.jobPollIntervalMs ?? 1e3;
24
+ this.staleLeaseThresholdMs = config.staleLeaseThresholdMs ?? 6e4;
25
+ this.maxClaimsPerTick = config.maxClaimsPerTick ?? 10;
26
+ this.maxSuspendedChecksPerTick = config.maxSuspendedChecksPerTick ?? 10;
27
+ this.maxOutboxFlushPerTick = config.maxOutboxFlushPerTick ?? 100;
28
+ }
29
+ // --------------------------------------------------------------------------
30
+ // Lifecycle
31
+ // --------------------------------------------------------------------------
32
+ async start() {
33
+ if (this.running) return;
34
+ this.running = true;
35
+ this.startTime = Date.now();
36
+ this.orchestrationTimer = setInterval(
37
+ () => void this.orchestrationTick(),
38
+ this.orchestrationIntervalMs
39
+ );
40
+ void this.orchestrationTick();
41
+ void this.processJobs();
42
+ const onSignal = () => void this.stop();
43
+ this.signalHandlers = [
44
+ { signal: "SIGTERM", handler: onSignal },
45
+ { signal: "SIGINT", handler: onSignal }
46
+ ];
47
+ for (const { signal, handler } of this.signalHandlers) {
48
+ process.once(signal, handler);
49
+ }
50
+ }
51
+ async stop() {
52
+ if (!this.running) return;
53
+ this.running = false;
54
+ if (this.orchestrationTimer) {
55
+ clearInterval(this.orchestrationTimer);
56
+ this.orchestrationTimer = null;
57
+ }
58
+ for (const { signal, handler } of this.signalHandlers) {
59
+ process.removeListener(signal, handler);
60
+ }
61
+ this.signalHandlers = [];
62
+ }
63
+ getStats() {
64
+ return {
65
+ workerId: this.workerId,
66
+ jobsProcessed: this.jobsProcessed,
67
+ orchestrationTicks: this.orchestrationTicks,
68
+ isRunning: this.running,
69
+ uptimeMs: this.running ? Date.now() - this.startTime : 0
70
+ };
71
+ }
72
+ // --------------------------------------------------------------------------
73
+ // Orchestration timer
74
+ // --------------------------------------------------------------------------
75
+ async orchestrationTick() {
76
+ try {
77
+ this.orchestrationTicks++;
78
+ await this.kernel.dispatch({
79
+ type: "run.claimPending",
80
+ workerId: this.workerId,
81
+ maxClaims: this.maxClaimsPerTick
82
+ });
83
+ const pollResult = await this.kernel.dispatch({
84
+ type: "stage.pollSuspended",
85
+ maxChecks: this.maxSuspendedChecksPerTick
86
+ });
87
+ for (const workflowRunId of pollResult.resumedWorkflowRunIds) {
88
+ await this.kernel.dispatch({
89
+ type: "run.transition",
90
+ workflowRunId
91
+ });
92
+ }
93
+ await this.kernel.dispatch({
94
+ type: "lease.reapStale",
95
+ staleThresholdMs: this.staleLeaseThresholdMs
96
+ });
97
+ await this.kernel.dispatch({
98
+ type: "outbox.flush",
99
+ maxEvents: this.maxOutboxFlushPerTick
100
+ });
101
+ } catch (error) {
102
+ console.error("[NodeHost] Orchestration tick error:", error);
103
+ }
104
+ }
105
+ // --------------------------------------------------------------------------
106
+ // Job processing loop
107
+ // --------------------------------------------------------------------------
108
+ async processJobs() {
109
+ while (this.running) {
110
+ try {
111
+ const job = await this.jobTransport.dequeue();
112
+ if (!job) {
113
+ await this.sleep(this.jobPollIntervalMs);
114
+ continue;
115
+ }
116
+ const config = job.payload.config || {};
117
+ const result = await this.kernel.dispatch({
118
+ type: "job.execute",
119
+ idempotencyKey: `job:${job.jobId}:attempt:${job.attempt}`,
120
+ workflowRunId: job.workflowRunId,
121
+ workflowId: job.workflowId,
122
+ stageId: job.stageId,
123
+ config
124
+ });
125
+ this.jobsProcessed++;
126
+ if (result.outcome === "completed") {
127
+ await this.jobTransport.complete(job.jobId);
128
+ await this.kernel.dispatch({
129
+ type: "run.transition",
130
+ workflowRunId: job.workflowRunId
131
+ });
132
+ } else if (result.outcome === "suspended") {
133
+ const nextPollAt = result.nextPollAt ?? new Date(Date.now() + 6e4);
134
+ await this.jobTransport.suspend(job.jobId, nextPollAt);
135
+ } else if (result.outcome === "failed") {
136
+ const canRetry = job.attempt < (job.maxAttempts ?? 3);
137
+ await this.jobTransport.fail(
138
+ job.jobId,
139
+ result.error ?? "Unknown error",
140
+ canRetry
141
+ );
142
+ }
143
+ } catch (error) {
144
+ console.error("[NodeHost] Job processing error:", error);
145
+ await this.sleep(5e3);
146
+ }
147
+ }
148
+ }
149
+ // --------------------------------------------------------------------------
150
+ // Helpers
151
+ // --------------------------------------------------------------------------
152
+ sleep(ms) {
153
+ return new Promise((resolve) => setTimeout(resolve, ms));
154
+ }
155
+ };
156
+ function createNodeHost(config) {
157
+ return new NodeHostImpl(config);
158
+ }
159
+
160
+ export { createNodeHost };
161
+ //# sourceMappingURL=index.js.map
162
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/host.ts"],"names":[],"mappings":";AAgEA,IAAM,eAAN,MAAuC;AAAA,EAC7B,OAAA,GAAU,KAAA;AAAA,EACV,aAAA,GAAgB,CAAA;AAAA,EAChB,kBAAA,GAAqB,CAAA;AAAA,EACrB,SAAA,GAAY,CAAA;AAAA,EACZ,kBAAA,GAA4D,IAAA;AAAA,EAC5D,iBAA4D,EAAC;AAAA,EAEpD,MAAA;AAAA,EACA,YAAA;AAAA,EACA,QAAA;AAAA,EACA,uBAAA;AAAA,EACA,iBAAA;AAAA,EACA,qBAAA;AAAA,EACA,gBAAA;AAAA,EACA,yBAAA;AAAA,EACA,qBAAA;AAAA,EAEjB,YAAY,MAAA,EAAwB;AAClC,IAAA,IAAA,CAAK,SAAS,MAAA,CAAO,MAAA;AACrB,IAAA,IAAA,CAAK,eAAe,MAAA,CAAO,YAAA;AAC3B,IAAA,IAAA,CAAK,WAAW,MAAA,CAAO,QAAA;AACvB,IAAA,IAAA,CAAK,uBAAA,GAA0B,OAAO,uBAAA,IAA2B,GAAA;AACjE,IAAA,IAAA,CAAK,iBAAA,GAAoB,OAAO,iBAAA,IAAqB,GAAA;AACrD,IAAA,IAAA,CAAK,qBAAA,GAAwB,OAAO,qBAAA,IAAyB,GAAA;AAC7D,IAAA,IAAA,CAAK,gBAAA,GAAmB,OAAO,gBAAA,IAAoB,EAAA;AACnD,IAAA,IAAA,CAAK,yBAAA,GAA4B,OAAO,yBAAA,IAA6B,EAAA;AACrE,IAAA,IAAA,CAAK,qBAAA,GAAwB,OAAO,qBAAA,IAAyB,GAAA;AAAA,EAC/D;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,KAAA,GAAuB;AAC3B,IAAA,IAAI,KAAK,OAAA,EAAS;AAElB,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,IAAA,IAAA,CAAK,SAAA,GAAY,KAAK,GAAA,EAAI;AAG1B,IAAA,IAAA,CAAK,kBAAA,GAAqB,WAAA;AAAA,MACxB,MAAM,KAAK,IAAA,CAAK,iBAAA,EAAkB;AAAA,MAClC,IAAA,CAAK;AAAA,KACP;AAGA,IAAA,KAAK,KAAK,iBAAA,EAAkB;AAG5B,IAAA,KAAK,KAAK,WAAA,EAAY;AAGtB,IAAA,MAAM,QAAA,GAAW,MAAM,KAAK,IAAA,CAAK,IAAA,EAAK;AACtC,IAAA,IAAA,CAAK,cAAA,GAAiB;AAAA,MACpB,EAAE,MAAA,EAAQ,SAAA,EAAW,OAAA,EAAS,QAAA,EAAS;AAAA,MACvC,EAAE,MAAA,EAAQ,QAAA,EAAU,OAAA,EAAS,QAAA;AAAS,KACxC;AACA,IAAA,KAAA,MAAW,EAAE,MAAA,EAAQ,OAAA,EAAQ,IAAK,KAAK,cAAA,EAAgB;AACrD,MAAA,OAAA,CAAQ,IAAA,CAAK,QAA0B,OAAO,CAAA;AAAA,IAChD;AAAA,EACF;AAAA,EAEA,MAAM,IAAA,GAAsB;AAC1B,IAAA,IAAI,CAAC,KAAK,OAAA,EAAS;AAEnB,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AAEf,IAAA,IAAI,KAAK,kBAAA,EAAoB;AAC3B,MAAA,aAAA,CAAc,KAAK,kBAAkB,CAAA;AACrC,MAAA,IAAA,CAAK,kBAAA,GAAqB,IAAA;AAAA,IAC5B;AAGA,IAAA,KAAA,MAAW,EAAE,MAAA,EAAQ,OAAA,EAAQ,IAAK,KAAK,cAAA,EAAgB;AACrD,MAAA,OAAA,CAAQ,cAAA,CAAe,QAAQ,OAAO,CAAA;AAAA,IACxC;AACA,IAAA,IAAA,CAAK,iBAAiB,EAAC;AAAA,EACzB;AAAA,EAEA,QAAA,GAAsB;AACpB,IAAA,OAAO;AAAA,MACL,UAAU,IAAA,CAAK,QAAA;AAAA,MACf,eAAe,IAAA,CAAK,aAAA;AAAA,MACpB,oBAAoB,IAAA,CAAK,kBAAA;AAAA,MACzB,WAAW,IAAA,CAAK,OAAA;AAAA,MAChB,UAAU,IAAA,CAAK,OAAA,GAAU,KAAK,GAAA,EAAI,GAAI,KAAK,SAAA,GAAY;AAAA,KACzD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,MAAc,iBAAA,GAAmC;AAC/C,IAAA,IAAI;AACF,MAAA,IAAA,CAAK,kBAAA,EAAA;AAGL,MAAA,MAAM,IAAA,CAAK,OAAO,QAAA,CAAS;AAAA,QACzB,IAAA,EAAM,kBAAA;AAAA,QACN,UAAU,IAAA,CAAK,QAAA;AAAA,QACf,WAAW,IAAA,CAAK;AAAA,OACjB,CAAA;AAGD,MAAA,MAAM,UAAA,GAAa,MAAM,IAAA,CAAK,MAAA,CAAO,QAAA,CAAS;AAAA,QAC5C,IAAA,EAAM,qBAAA;AAAA,QACN,WAAW,IAAA,CAAK;AAAA,OACjB,CAAA;AACD,MAAA,KAAA,MAAW,aAAA,IAAiB,WAAW,qBAAA,EAAuB;AAC5D,QAAA,MAAM,IAAA,CAAK,OAAO,QAAA,CAAS;AAAA,UACzB,IAAA,EAAM,gBAAA;AAAA,UACN;AAAA,SACD,CAAA;AAAA,MACH;AAGA,MAAA,MAAM,IAAA,CAAK,OAAO,QAAA,CAAS;AAAA,QACzB,IAAA,EAAM,iBAAA;AAAA,QACN,kBAAkB,IAAA,CAAK;AAAA,OACxB,CAAA;AAGD,MAAA,MAAM,IAAA,CAAK,OAAO,QAAA,CAAS;AAAA,QACzB,IAAA,EAAM,cAAA;AAAA,QACN,WAAW,IAAA,CAAK;AAAA,OACjB,CAAA;AAAA,IACH,SAAS,KAAA,EAAO;AAEd,MAAA,OAAA,CAAQ,KAAA,CAAM,wCAAwC,KAAK,CAAA;AAAA,IAC7D;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,MAAc,WAAA,GAA6B;AACzC,IAAA,OAAO,KAAK,OAAA,EAAS;AACnB,MAAA,IAAI;AACF,QAAA,MAAM,GAAA,GAAM,MAAM,IAAA,CAAK,YAAA,CAAa,OAAA,EAAQ;AAE5C,QAAA,IAAI,CAAC,GAAA,EAAK;AACR,UAAA,MAAM,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,iBAAiB,CAAA;AACvC,UAAA;AAAA,QACF;AAEA,QAAA,MAAM,MAAA,GACH,GAAA,CAAI,OAAA,CAAiD,MAAA,IAAU,EAAC;AAEnE,QAAA,MAAM,MAAA,GAAS,MAAM,IAAA,CAAK,MAAA,CAAO,QAAA,CAAS;AAAA,UACxC,IAAA,EAAM,aAAA;AAAA,UACN,gBAAgB,CAAA,IAAA,EAAO,GAAA,CAAI,KAAK,CAAA,SAAA,EAAY,IAAI,OAAO,CAAA,CAAA;AAAA,UACvD,eAAe,GAAA,CAAI,aAAA;AAAA,UACnB,YAAY,GAAA,CAAI,UAAA;AAAA,UAChB,SAAS,GAAA,CAAI,OAAA;AAAA,UACb;AAAA,SACD,CAAA;AAED,QAAA,IAAA,CAAK,aAAA,EAAA;AAEL,QAAA,IAAI,MAAA,CAAO,YAAY,WAAA,EAAa;AAClC,UAAA,MAAM,IAAA,CAAK,YAAA,CAAa,QAAA,CAAS,GAAA,CAAI,KAAK,CAAA;AAC1C,UAAA,MAAM,IAAA,CAAK,OAAO,QAAA,CAAS;AAAA,YACzB,IAAA,EAAM,gBAAA;AAAA,YACN,eAAe,GAAA,CAAI;AAAA,WACpB,CAAA;AAAA,QACH,CAAA,MAAA,IAAW,MAAA,CAAO,OAAA,KAAY,WAAA,EAAa;AACzC,UAAA,MAAM,UAAA,GAAa,OAAO,UAAA,IAAc,IAAI,KAAK,IAAA,CAAK,GAAA,KAAQ,GAAM,CAAA;AACpE,UAAA,MAAM,IAAA,CAAK,YAAA,CAAa,OAAA,CAAQ,GAAA,CAAI,OAAO,UAAU,CAAA;AAAA,QACvD,CAAA,MAAA,IAAW,MAAA,CAAO,OAAA,KAAY,QAAA,EAAU;AACtC,UAAA,MAAM,QAAA,GAAW,GAAA,CAAI,OAAA,IAAW,GAAA,CAAI,WAAA,IAAe,CAAA,CAAA;AACnD,UAAA,MAAM,KAAK,YAAA,CAAa,IAAA;AAAA,YACtB,GAAA,CAAI,KAAA;AAAA,YACJ,OAAO,KAAA,IAAS,eAAA;AAAA,YAChB;AAAA,WACF;AAAA,QACF;AAAA,MACF,SAAS,KAAA,EAAO;AAEd,QAAA,OAAA,CAAQ,KAAA,CAAM,oCAAoC,KAAK,CAAA;AACvD,QAAA,MAAM,IAAA,CAAK,MAAM,GAAK,CAAA;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMQ,MAAM,EAAA,EAA2B;AACvC,IAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,YAAY,UAAA,CAAW,OAAA,EAAS,EAAE,CAAC,CAAA;AAAA,EACzD;AACF,CAAA;AAMO,SAAS,eAAe,MAAA,EAAkC;AAC/D,EAAA,OAAO,IAAI,aAAa,MAAM,CAAA;AAChC","file":"index.js","sourcesContent":["/**\n * Node Host for Workflow Engine Command Kernel\n *\n * Wraps the environment-agnostic kernel with Node.js process loops,\n * signal handling, and job processing. The host dispatches kernel\n * commands on intervals and manages the job dequeue/execute cycle.\n *\n * The kernel remains unaware of process state — all timers, signals,\n * and loop pacing live here.\n */\n\nimport type { JobTransport, Kernel } from \"@bratsos/workflow-engine/kernel\";\n\n// ============================================================================\n// Public interfaces\n// ============================================================================\n\nexport interface NodeHostConfig {\n /** Kernel instance to dispatch commands to. */\n kernel: Kernel;\n\n /** Job transport for dequeue/complete/suspend/fail. */\n jobTransport: JobTransport;\n\n /** Unique worker identifier. */\n workerId: string;\n\n /** Orchestration poll interval in milliseconds (default: 10_000). */\n orchestrationIntervalMs?: number;\n\n /** Job dequeue poll interval when queue is empty (default: 1_000). */\n jobPollIntervalMs?: number;\n\n /** Stale lease threshold in milliseconds (default: 60_000). */\n staleLeaseThresholdMs?: number;\n\n /** Max pending runs to claim per orchestration tick (default: 10). */\n maxClaimsPerTick?: number;\n\n /** Max suspended stages to check per tick (default: 10). */\n maxSuspendedChecksPerTick?: number;\n\n /** Max outbox events to flush per tick (default: 100). */\n maxOutboxFlushPerTick?: number;\n}\n\nexport interface HostStats {\n workerId: string;\n jobsProcessed: number;\n orchestrationTicks: number;\n isRunning: boolean;\n uptimeMs: number;\n}\n\nexport interface NodeHost {\n start(): Promise<void>;\n stop(): Promise<void>;\n getStats(): HostStats;\n}\n\n// ============================================================================\n// Implementation\n// ============================================================================\n\nclass NodeHostImpl implements NodeHost {\n private running = false;\n private jobsProcessed = 0;\n private orchestrationTicks = 0;\n private startTime = 0;\n private orchestrationTimer: ReturnType<typeof setInterval> | null = null;\n private signalHandlers: { signal: string; handler: () => void }[] = [];\n\n private readonly kernel: Kernel;\n private readonly jobTransport: JobTransport;\n private readonly workerId: string;\n private readonly orchestrationIntervalMs: number;\n private readonly jobPollIntervalMs: number;\n private readonly staleLeaseThresholdMs: number;\n private readonly maxClaimsPerTick: number;\n private readonly maxSuspendedChecksPerTick: number;\n private readonly maxOutboxFlushPerTick: number;\n\n constructor(config: NodeHostConfig) {\n this.kernel = config.kernel;\n this.jobTransport = config.jobTransport;\n this.workerId = config.workerId;\n this.orchestrationIntervalMs = config.orchestrationIntervalMs ?? 10_000;\n this.jobPollIntervalMs = config.jobPollIntervalMs ?? 1_000;\n this.staleLeaseThresholdMs = config.staleLeaseThresholdMs ?? 60_000;\n this.maxClaimsPerTick = config.maxClaimsPerTick ?? 10;\n this.maxSuspendedChecksPerTick = config.maxSuspendedChecksPerTick ?? 10;\n this.maxOutboxFlushPerTick = config.maxOutboxFlushPerTick ?? 100;\n }\n\n // --------------------------------------------------------------------------\n // Lifecycle\n // --------------------------------------------------------------------------\n\n async start(): Promise<void> {\n if (this.running) return;\n\n this.running = true;\n this.startTime = Date.now();\n\n // Start orchestration timer\n this.orchestrationTimer = setInterval(\n () => void this.orchestrationTick(),\n this.orchestrationIntervalMs,\n );\n\n // Immediate first tick\n void this.orchestrationTick();\n\n // Start job processing loop (runs until stop())\n void this.processJobs();\n\n // Signal handlers — use wrapper functions so we can remove them on stop\n const onSignal = () => void this.stop();\n this.signalHandlers = [\n { signal: \"SIGTERM\", handler: onSignal },\n { signal: \"SIGINT\", handler: onSignal },\n ];\n for (const { signal, handler } of this.signalHandlers) {\n process.once(signal as NodeJS.Signals, handler);\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n\n this.running = false;\n\n if (this.orchestrationTimer) {\n clearInterval(this.orchestrationTimer);\n this.orchestrationTimer = null;\n }\n\n // Remove signal handlers to avoid leaks\n for (const { signal, handler } of this.signalHandlers) {\n process.removeListener(signal, handler);\n }\n this.signalHandlers = [];\n }\n\n getStats(): HostStats {\n return {\n workerId: this.workerId,\n jobsProcessed: this.jobsProcessed,\n orchestrationTicks: this.orchestrationTicks,\n isRunning: this.running,\n uptimeMs: this.running ? Date.now() - this.startTime : 0,\n };\n }\n\n // --------------------------------------------------------------------------\n // Orchestration timer\n // --------------------------------------------------------------------------\n\n private async orchestrationTick(): Promise<void> {\n try {\n this.orchestrationTicks++;\n\n // 1. Claim pending runs → enqueue first-stage jobs\n await this.kernel.dispatch({\n type: \"run.claimPending\",\n workerId: this.workerId,\n maxClaims: this.maxClaimsPerTick,\n });\n\n // 2. Poll suspended stages → resume if ready\n const pollResult = await this.kernel.dispatch({\n type: \"stage.pollSuspended\",\n maxChecks: this.maxSuspendedChecksPerTick,\n });\n for (const workflowRunId of pollResult.resumedWorkflowRunIds) {\n await this.kernel.dispatch({\n type: \"run.transition\",\n workflowRunId,\n });\n }\n\n // 3. Reap stale leases → release crashed worker locks\n await this.kernel.dispatch({\n type: \"lease.reapStale\",\n staleThresholdMs: this.staleLeaseThresholdMs,\n });\n\n // 4. Flush outbox → publish pending events through EventSink\n await this.kernel.dispatch({\n type: \"outbox.flush\",\n maxEvents: this.maxOutboxFlushPerTick,\n });\n } catch (error) {\n // Orchestration errors are non-fatal — the next tick will retry\n console.error(\"[NodeHost] Orchestration tick error:\", error);\n }\n }\n\n // --------------------------------------------------------------------------\n // Job processing loop\n // --------------------------------------------------------------------------\n\n private async processJobs(): Promise<void> {\n while (this.running) {\n try {\n const job = await this.jobTransport.dequeue();\n\n if (!job) {\n await this.sleep(this.jobPollIntervalMs);\n continue;\n }\n\n const config =\n (job.payload as { config?: Record<string, unknown> }).config || {};\n\n const result = await this.kernel.dispatch({\n type: \"job.execute\",\n idempotencyKey: `job:${job.jobId}:attempt:${job.attempt}`,\n workflowRunId: job.workflowRunId,\n workflowId: job.workflowId,\n stageId: job.stageId,\n config,\n });\n\n this.jobsProcessed++;\n\n if (result.outcome === \"completed\") {\n await this.jobTransport.complete(job.jobId);\n await this.kernel.dispatch({\n type: \"run.transition\",\n workflowRunId: job.workflowRunId,\n });\n } else if (result.outcome === \"suspended\") {\n const nextPollAt = result.nextPollAt ?? new Date(Date.now() + 60_000);\n await this.jobTransport.suspend(job.jobId, nextPollAt);\n } else if (result.outcome === \"failed\") {\n const canRetry = job.attempt < (job.maxAttempts ?? 3);\n await this.jobTransport.fail(\n job.jobId,\n result.error ?? \"Unknown error\",\n canRetry,\n );\n }\n } catch (error) {\n // Job processing errors are non-fatal — back off and retry\n console.error(\"[NodeHost] Job processing error:\", error);\n await this.sleep(5_000);\n }\n }\n }\n\n // --------------------------------------------------------------------------\n // Helpers\n // --------------------------------------------------------------------------\n\n private sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\nexport function createNodeHost(config: NodeHostConfig): NodeHost {\n return new NodeHostImpl(config);\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@bratsos/workflow-engine-host-node",
3
+ "version": "0.2.2",
4
+ "description": "Node.js host for @bratsos/workflow-engine command kernel",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Alex Bratsos",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "test": "vitest run",
26
+ "typecheck": "tsc --noEmit"
27
+ },
28
+ "dependencies": {
29
+ "@bratsos/workflow-engine": "workspace:*"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22",
33
+ "tsup": "^8.5.1",
34
+ "typescript": "^5.8.3",
35
+ "vitest": "^3.2.4",
36
+ "zod": "^4.1.12"
37
+ },
38
+ "engines": {
39
+ "node": ">=22.11.0"
40
+ }
41
+ }