station-broadcast 1.0.0
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/LICENSE +21 -0
- package/dist/adapters/index.d.ts +20 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/memory.d.ts +22 -0
- package/dist/adapters/memory.d.ts.map +1 -0
- package/dist/adapters/memory.js +108 -0
- package/dist/adapters/memory.js.map +1 -0
- package/dist/broadcast-runner.d.ts +78 -0
- package/dist/broadcast-runner.d.ts.map +1 -0
- package/dist/broadcast-runner.js +659 -0
- package/dist/broadcast-runner.js.map +1 -0
- package/dist/broadcast.d.ts +87 -0
- package/dist/broadcast.d.ts.map +1 -0
- package/dist/broadcast.js +209 -0
- package/dist/broadcast.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/subscribers/console.d.ts +44 -0
- package/dist/subscribers/console.d.ts.map +1 -0
- package/dist/subscribers/console.js +34 -0
- package/dist/subscribers/console.js.map +1 -0
- package/dist/subscribers/index.d.ts +53 -0
- package/dist/subscribers/index.d.ts.map +1 -0
- package/dist/subscribers/index.js +2 -0
- package/dist/subscribers/index.js.map +1 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +10 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +42 -0
- package/dist/util.js.map +1 -0
- package/package.json +33 -0
- package/src/adapters/index.ts +32 -0
- package/src/adapters/memory.ts +131 -0
- package/src/broadcast-runner.ts +747 -0
- package/src/broadcast.ts +293 -0
- package/src/config.ts +31 -0
- package/src/errors.ts +19 -0
- package/src/index.ts +31 -0
- package/src/subscribers/console.ts +66 -0
- package/src/subscribers/index.ts +35 -0
- package/src/types.ts +47 -0
- package/src/util.ts +49 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { parseInterval } from "station-signal";
|
|
4
|
+
import { configureBroadcast } from "./config.js";
|
|
5
|
+
import { BroadcastMemoryAdapter } from "./adapters/memory.js";
|
|
6
|
+
import { isBroadcast } from "./util.js";
|
|
7
|
+
export class BroadcastRunner {
|
|
8
|
+
signalRunner;
|
|
9
|
+
signalAdapter;
|
|
10
|
+
adapter;
|
|
11
|
+
broadcastsDir;
|
|
12
|
+
pollIntervalMs;
|
|
13
|
+
subscribers;
|
|
14
|
+
registry = new Map();
|
|
15
|
+
recurringSchedules = new Map();
|
|
16
|
+
running = false;
|
|
17
|
+
stopping = false;
|
|
18
|
+
ticking = false;
|
|
19
|
+
pollTimer = null;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.signalRunner = options.signalRunner;
|
|
22
|
+
this.signalAdapter = options.signalRunner.getAdapter();
|
|
23
|
+
const adapter = options.adapter ?? new BroadcastMemoryAdapter();
|
|
24
|
+
configureBroadcast({ adapter });
|
|
25
|
+
this.adapter = adapter;
|
|
26
|
+
this.broadcastsDir = options.broadcastsDir;
|
|
27
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 1000;
|
|
28
|
+
this.subscribers = options.subscribers ? [...options.subscribers] : [];
|
|
29
|
+
}
|
|
30
|
+
/** List all registered broadcast definitions with metadata. */
|
|
31
|
+
listRegistered() {
|
|
32
|
+
return Array.from(this.registry.values()).map((def) => ({
|
|
33
|
+
name: def.name,
|
|
34
|
+
nodeCount: def.nodes.length,
|
|
35
|
+
failurePolicy: def.failurePolicy,
|
|
36
|
+
timeout: def.timeout,
|
|
37
|
+
interval: def.interval,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
/** Check whether a broadcast is registered by name. */
|
|
41
|
+
hasBroadcast(name) {
|
|
42
|
+
return this.registry.has(name);
|
|
43
|
+
}
|
|
44
|
+
/** Register a broadcast definition explicitly (alternative to auto-discovery). */
|
|
45
|
+
register(definition) {
|
|
46
|
+
if (this.registry.has(definition.name)) {
|
|
47
|
+
console.warn(`[station-broadcast] Duplicate broadcast name "${definition.name}" — overwriting.`);
|
|
48
|
+
}
|
|
49
|
+
this.registry.set(definition.name, definition);
|
|
50
|
+
if (definition.interval && !this.recurringSchedules.has(definition.name)) {
|
|
51
|
+
this.scheduleRecurring(definition);
|
|
52
|
+
}
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
subscribe(subscriber) {
|
|
56
|
+
this.subscribers.push(subscriber);
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
async getBroadcastRun(id) {
|
|
60
|
+
return this.adapter.getBroadcastRun(id);
|
|
61
|
+
}
|
|
62
|
+
async getNodeRuns(broadcastRunId) {
|
|
63
|
+
return this.adapter.getNodeRuns(broadcastRunId);
|
|
64
|
+
}
|
|
65
|
+
async waitForBroadcastRun(id, opts) {
|
|
66
|
+
const pollMs = opts?.pollMs ?? 200;
|
|
67
|
+
const timeoutMs = opts?.timeoutMs ?? 60_000;
|
|
68
|
+
const deadline = Date.now() + timeoutMs;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
const run = await this.adapter.getBroadcastRun(id);
|
|
71
|
+
if (!run)
|
|
72
|
+
return null;
|
|
73
|
+
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
|
|
74
|
+
return run;
|
|
75
|
+
}
|
|
76
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
77
|
+
}
|
|
78
|
+
return this.adapter.getBroadcastRun(id);
|
|
79
|
+
}
|
|
80
|
+
async cancel(broadcastRunId) {
|
|
81
|
+
const bRun = await this.adapter.getBroadcastRun(broadcastRunId);
|
|
82
|
+
if (!bRun)
|
|
83
|
+
return false;
|
|
84
|
+
if (bRun.status === "completed" || bRun.status === "failed" || bRun.status === "cancelled") {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// Cancel all running/pending nodes
|
|
88
|
+
const nodeRuns = await this.adapter.getNodeRuns(broadcastRunId);
|
|
89
|
+
for (const nr of nodeRuns) {
|
|
90
|
+
if (nr.status === "running" && nr.signalRunId) {
|
|
91
|
+
await this.signalRunner.cancel(nr.signalRunId);
|
|
92
|
+
}
|
|
93
|
+
if (nr.status === "pending" || nr.status === "running") {
|
|
94
|
+
await this.adapter.updateNodeRun(nr.id, {
|
|
95
|
+
status: "skipped",
|
|
96
|
+
skipReason: "cancelled",
|
|
97
|
+
completedAt: new Date(),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// H5: Mutate bRun before emitting so subscribers see current state
|
|
102
|
+
bRun.status = "cancelled";
|
|
103
|
+
bRun.completedAt = new Date();
|
|
104
|
+
await this.adapter.updateBroadcastRun(broadcastRunId, {
|
|
105
|
+
status: bRun.status,
|
|
106
|
+
completedAt: bRun.completedAt,
|
|
107
|
+
});
|
|
108
|
+
this.emit("onBroadcastCancelled", { broadcastRun: bRun });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Trigger a broadcast by name. Prefer this over `definition.trigger()` as it
|
|
113
|
+
* writes directly to this runner's adapter instead of the global singleton.
|
|
114
|
+
*/
|
|
115
|
+
async trigger(broadcastName, input) {
|
|
116
|
+
const definition = this.registry.get(broadcastName);
|
|
117
|
+
if (!definition) {
|
|
118
|
+
throw new Error(`No broadcast definition registered for "${broadcastName}"`);
|
|
119
|
+
}
|
|
120
|
+
const id = this.adapter.generateId();
|
|
121
|
+
const bRun = {
|
|
122
|
+
id,
|
|
123
|
+
broadcastName,
|
|
124
|
+
input: JSON.stringify(input),
|
|
125
|
+
status: "pending",
|
|
126
|
+
failurePolicy: definition.failurePolicy,
|
|
127
|
+
timeout: definition.timeout,
|
|
128
|
+
createdAt: new Date(),
|
|
129
|
+
};
|
|
130
|
+
await this.adapter.addBroadcastRun(bRun);
|
|
131
|
+
this.emit("onBroadcastQueued", { broadcastRun: bRun });
|
|
132
|
+
return id;
|
|
133
|
+
}
|
|
134
|
+
async start() {
|
|
135
|
+
if (this.running) {
|
|
136
|
+
throw new Error("[station-broadcast] Runner is already started");
|
|
137
|
+
}
|
|
138
|
+
if (this.broadcastsDir) {
|
|
139
|
+
await this.discover(resolve(this.broadcastsDir));
|
|
140
|
+
}
|
|
141
|
+
const shutdown = () => {
|
|
142
|
+
console.log("[station-broadcast] Received shutdown signal, stopping...");
|
|
143
|
+
this.stop({ graceful: true, timeoutMs: 10_000 }).catch((err) => {
|
|
144
|
+
console.error("[station-broadcast] Error during shutdown:", err);
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
process.once("SIGINT", shutdown);
|
|
148
|
+
process.once("SIGTERM", shutdown);
|
|
149
|
+
this.running = true;
|
|
150
|
+
while (this.running) {
|
|
151
|
+
try {
|
|
152
|
+
await this.tick();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
console.error("[station-broadcast] tick() failed:", err);
|
|
156
|
+
}
|
|
157
|
+
await this.sleep(this.pollIntervalMs);
|
|
158
|
+
}
|
|
159
|
+
process.removeListener("SIGINT", shutdown);
|
|
160
|
+
process.removeListener("SIGTERM", shutdown);
|
|
161
|
+
}
|
|
162
|
+
async stop(options) {
|
|
163
|
+
if (this.stopping)
|
|
164
|
+
return;
|
|
165
|
+
this.stopping = true;
|
|
166
|
+
this.running = false;
|
|
167
|
+
if (this.pollTimer) {
|
|
168
|
+
clearTimeout(this.pollTimer);
|
|
169
|
+
this.pollTimer = null;
|
|
170
|
+
}
|
|
171
|
+
if (options?.graceful) {
|
|
172
|
+
const timeout = options.timeoutMs ?? 10_000;
|
|
173
|
+
const deadline = Date.now() + timeout;
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
const running = await this.adapter.getBroadcastRunsRunning();
|
|
176
|
+
if (running.length === 0)
|
|
177
|
+
break;
|
|
178
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
await this.adapter.close?.();
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.error("[station-broadcast] Error closing adapter:", err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
emit(event, data) {
|
|
189
|
+
for (const sub of this.subscribers) {
|
|
190
|
+
try {
|
|
191
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
192
|
+
sub[event]?.(data);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error(`[station-broadcast] Subscriber error in ${String(event)}:`, err);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
sleep(ms) {
|
|
200
|
+
return new Promise((res) => {
|
|
201
|
+
this.pollTimer = setTimeout(res, ms);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async discover(dir) {
|
|
205
|
+
let files;
|
|
206
|
+
try {
|
|
207
|
+
const entries = await readdir(dir, { recursive: true });
|
|
208
|
+
files = entries
|
|
209
|
+
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
|
|
210
|
+
.map((f) => join(dir, f));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
console.error(`[station-broadcast] Cannot read broadcastsDir: ${dir}`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const filePath of files) {
|
|
217
|
+
try {
|
|
218
|
+
const mod = await import(filePath);
|
|
219
|
+
for (const value of Object.values(mod)) {
|
|
220
|
+
if (isBroadcast(value)) {
|
|
221
|
+
this.registry.set(value.name, value);
|
|
222
|
+
this.emit("onBroadcastDiscovered", { broadcastName: value.name, filePath });
|
|
223
|
+
if (value.interval && !this.recurringSchedules.has(value.name)) {
|
|
224
|
+
this.scheduleRecurring(value);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
console.warn(`[station-broadcast] Skipping ${filePath} — failed to import (if .ts, ensure a TypeScript loader like tsx is active):`, err);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
scheduleRecurring(def) {
|
|
235
|
+
const ms = parseInterval(def.interval);
|
|
236
|
+
this.recurringSchedules.set(def.name, {
|
|
237
|
+
broadcastName: def.name,
|
|
238
|
+
interval: def.interval,
|
|
239
|
+
nextRunAt: new Date(Date.now() + ms),
|
|
240
|
+
input: def.recurringInput ? JSON.stringify(def.recurringInput) : undefined,
|
|
241
|
+
failurePolicy: def.failurePolicy,
|
|
242
|
+
timeout: def.timeout,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// ─── Tick ──────────────────────────────────────────────────────────
|
|
246
|
+
async tick() {
|
|
247
|
+
if (this.ticking)
|
|
248
|
+
return;
|
|
249
|
+
this.ticking = true;
|
|
250
|
+
try {
|
|
251
|
+
await this.tickRecurring();
|
|
252
|
+
// Advance running broadcasts first
|
|
253
|
+
const running = await this.adapter.getBroadcastRunsRunning();
|
|
254
|
+
for (const bRun of running) {
|
|
255
|
+
await this.advanceBroadcast(bRun);
|
|
256
|
+
}
|
|
257
|
+
// Pick up pending broadcasts
|
|
258
|
+
const due = await this.adapter.getBroadcastRunsDue();
|
|
259
|
+
for (const bRun of due) {
|
|
260
|
+
await this.initBroadcast(bRun);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
this.ticking = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async tickRecurring() {
|
|
268
|
+
const now = new Date();
|
|
269
|
+
for (const [name, schedule] of this.recurringSchedules) {
|
|
270
|
+
if (schedule.nextRunAt > now)
|
|
271
|
+
continue;
|
|
272
|
+
const hasPendingOrRunning = await this.adapter.hasBroadcastRunWithStatus(name, ["pending", "running"]);
|
|
273
|
+
if (hasPendingOrRunning) {
|
|
274
|
+
const ms = parseInterval(schedule.interval);
|
|
275
|
+
schedule.nextRunAt = new Date(Date.now() + ms);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const id = this.adapter.generateId();
|
|
279
|
+
const bRun = {
|
|
280
|
+
id,
|
|
281
|
+
broadcastName: name,
|
|
282
|
+
input: schedule.input ?? JSON.stringify({}),
|
|
283
|
+
status: "pending",
|
|
284
|
+
failurePolicy: schedule.failurePolicy,
|
|
285
|
+
timeout: schedule.timeout,
|
|
286
|
+
createdAt: new Date(),
|
|
287
|
+
};
|
|
288
|
+
await this.adapter.addBroadcastRun(bRun);
|
|
289
|
+
this.emit("onBroadcastQueued", { broadcastRun: bRun });
|
|
290
|
+
const ms = parseInterval(schedule.interval);
|
|
291
|
+
schedule.nextRunAt = new Date(Date.now() + ms);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// ─── Init broadcast ────────────────────────────────────────────────
|
|
295
|
+
async initBroadcast(bRun) {
|
|
296
|
+
// H4: Optimistic lock — re-read status to avoid double-init from concurrent ticks
|
|
297
|
+
const fresh = await this.adapter.getBroadcastRun(bRun.id);
|
|
298
|
+
if (!fresh || fresh.status !== "pending")
|
|
299
|
+
return;
|
|
300
|
+
const definition = this.registry.get(bRun.broadcastName);
|
|
301
|
+
if (!definition) {
|
|
302
|
+
const error = `No broadcast definition registered for "${bRun.broadcastName}"`;
|
|
303
|
+
// H6: Mutate bRun before emitting so subscribers see current state
|
|
304
|
+
bRun.status = "failed";
|
|
305
|
+
bRun.completedAt = new Date();
|
|
306
|
+
bRun.error = error;
|
|
307
|
+
await this.adapter.updateBroadcastRun(bRun.id, {
|
|
308
|
+
status: bRun.status,
|
|
309
|
+
completedAt: bRun.completedAt,
|
|
310
|
+
error,
|
|
311
|
+
});
|
|
312
|
+
this.emit("onBroadcastFailed", { broadcastRun: bRun, error });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// Mark as running
|
|
316
|
+
bRun.status = "running";
|
|
317
|
+
bRun.startedAt = new Date();
|
|
318
|
+
await this.adapter.updateBroadcastRun(bRun.id, {
|
|
319
|
+
status: bRun.status,
|
|
320
|
+
startedAt: bRun.startedAt,
|
|
321
|
+
});
|
|
322
|
+
this.emit("onBroadcastStarted", { broadcastRun: bRun });
|
|
323
|
+
// Create node run records for all nodes
|
|
324
|
+
const nodeRunsByName = new Map();
|
|
325
|
+
for (const node of definition.nodes) {
|
|
326
|
+
const nodeRun = {
|
|
327
|
+
id: this.adapter.generateId(),
|
|
328
|
+
broadcastRunId: bRun.id,
|
|
329
|
+
nodeName: node.name,
|
|
330
|
+
signalName: node.signalName,
|
|
331
|
+
status: "pending",
|
|
332
|
+
};
|
|
333
|
+
await this.adapter.addNodeRun(nodeRun);
|
|
334
|
+
nodeRunsByName.set(node.name, nodeRun);
|
|
335
|
+
}
|
|
336
|
+
// Trigger ready nodes (root nodes with no dependencies)
|
|
337
|
+
await this.triggerReadyNodes(bRun, definition, nodeRunsByName);
|
|
338
|
+
}
|
|
339
|
+
// ─── Advance broadcast ─────────────────────────────────────────────
|
|
340
|
+
async advanceBroadcast(bRun) {
|
|
341
|
+
const definition = this.registry.get(bRun.broadcastName);
|
|
342
|
+
if (!definition) {
|
|
343
|
+
await this.adapter.updateBroadcastRun(bRun.id, {
|
|
344
|
+
status: "failed",
|
|
345
|
+
completedAt: new Date(),
|
|
346
|
+
error: `Definition for "${bRun.broadcastName}" not found`,
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// M8: Broadcast-level timeout check
|
|
351
|
+
if (bRun.timeout && bRun.startedAt) {
|
|
352
|
+
const elapsed = Date.now() - bRun.startedAt.getTime();
|
|
353
|
+
if (elapsed > bRun.timeout) {
|
|
354
|
+
await this.cancel(bRun.id);
|
|
355
|
+
const error = `Broadcast timed out after ${bRun.timeout}ms`;
|
|
356
|
+
bRun.status = "failed";
|
|
357
|
+
bRun.completedAt = new Date();
|
|
358
|
+
bRun.error = error;
|
|
359
|
+
await this.adapter.updateBroadcastRun(bRun.id, {
|
|
360
|
+
status: "failed",
|
|
361
|
+
completedAt: bRun.completedAt,
|
|
362
|
+
error,
|
|
363
|
+
});
|
|
364
|
+
this.emit("onBroadcastFailed", { broadcastRun: bRun, error });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const nodeRuns = await this.adapter.getNodeRuns(bRun.id);
|
|
369
|
+
const nodeRunsByName = new Map(nodeRuns.map((n) => [n.nodeName, n]));
|
|
370
|
+
// Check running nodes for signal completion
|
|
371
|
+
for (const nodeRun of nodeRuns) {
|
|
372
|
+
if (nodeRun.status !== "running" || !nodeRun.signalRunId)
|
|
373
|
+
continue;
|
|
374
|
+
const signalRun = await this.signalAdapter.getRun(nodeRun.signalRunId);
|
|
375
|
+
if (!signalRun)
|
|
376
|
+
continue;
|
|
377
|
+
if (signalRun.status === "completed") {
|
|
378
|
+
await this.adapter.updateNodeRun(nodeRun.id, {
|
|
379
|
+
status: "completed",
|
|
380
|
+
output: signalRun.output,
|
|
381
|
+
completedAt: new Date(),
|
|
382
|
+
});
|
|
383
|
+
nodeRun.status = "completed";
|
|
384
|
+
nodeRun.output = signalRun.output;
|
|
385
|
+
this.emit("onNodeCompleted", { broadcastRun: bRun, nodeRun });
|
|
386
|
+
}
|
|
387
|
+
else if (signalRun.status === "failed" || signalRun.status === "cancelled") {
|
|
388
|
+
const error = signalRun.error ?? `Signal run ${signalRun.status}`;
|
|
389
|
+
await this.adapter.updateNodeRun(nodeRun.id, {
|
|
390
|
+
status: "failed",
|
|
391
|
+
error,
|
|
392
|
+
completedAt: new Date(),
|
|
393
|
+
});
|
|
394
|
+
nodeRun.status = "failed";
|
|
395
|
+
nodeRun.error = error;
|
|
396
|
+
this.emit("onNodeFailed", { broadcastRun: bRun, nodeRun, error });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// H3: Only run failure handling when there are unresolved nodes to process
|
|
400
|
+
const failedNodes = nodeRuns.filter((n) => n.status === "failed");
|
|
401
|
+
const hasUnresolvedNodes = nodeRuns.some((n) => n.status === "pending" || n.status === "running");
|
|
402
|
+
if (failedNodes.length > 0 && hasUnresolvedNodes) {
|
|
403
|
+
const handled = await this.handleFailure(bRun, definition, nodeRunsByName, failedNodes);
|
|
404
|
+
if (handled)
|
|
405
|
+
return; // broadcast was terminated
|
|
406
|
+
}
|
|
407
|
+
// Trigger newly ready nodes
|
|
408
|
+
await this.triggerReadyNodes(bRun, definition, nodeRunsByName);
|
|
409
|
+
// Check if broadcast is complete
|
|
410
|
+
const allTerminal = [...nodeRunsByName.values()].every((n) => n.status === "completed" || n.status === "skipped" || n.status === "failed");
|
|
411
|
+
if (allTerminal) {
|
|
412
|
+
const failedNames = [...nodeRunsByName.values()]
|
|
413
|
+
.filter((n) => n.status === "failed")
|
|
414
|
+
.map((n) => n.nodeName);
|
|
415
|
+
const anyFailed = failedNames.length > 0;
|
|
416
|
+
if (anyFailed && bRun.failurePolicy !== "continue") {
|
|
417
|
+
const error = `Nodes failed: ${failedNames.join(", ")}`;
|
|
418
|
+
bRun.status = "failed";
|
|
419
|
+
bRun.completedAt = new Date();
|
|
420
|
+
bRun.error = error;
|
|
421
|
+
await this.adapter.updateBroadcastRun(bRun.id, {
|
|
422
|
+
status: bRun.status,
|
|
423
|
+
completedAt: bRun.completedAt,
|
|
424
|
+
error,
|
|
425
|
+
});
|
|
426
|
+
this.emit("onBroadcastFailed", { broadcastRun: bRun, error });
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
// H2: For "continue" policy, still populate error so callers can detect partial failure
|
|
430
|
+
bRun.status = "completed";
|
|
431
|
+
bRun.completedAt = new Date();
|
|
432
|
+
if (anyFailed) {
|
|
433
|
+
bRun.error = `Completed with failures: ${failedNames.join(", ")}`;
|
|
434
|
+
}
|
|
435
|
+
await this.adapter.updateBroadcastRun(bRun.id, {
|
|
436
|
+
status: bRun.status,
|
|
437
|
+
completedAt: bRun.completedAt,
|
|
438
|
+
error: bRun.error,
|
|
439
|
+
});
|
|
440
|
+
this.emit("onBroadcastCompleted", { broadcastRun: bRun });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// ─── Failure handling ──────────────────────────────────────────────
|
|
445
|
+
/**
|
|
446
|
+
* Apply the failure policy. Returns true if the broadcast was terminated
|
|
447
|
+
* (fail-fast), false if processing should continue.
|
|
448
|
+
*/
|
|
449
|
+
async handleFailure(bRun, definition, nodeRunsByName, failedNodes) {
|
|
450
|
+
const policy = bRun.failurePolicy;
|
|
451
|
+
if (policy === "fail-fast") {
|
|
452
|
+
// Cancel all running signal runs and mark non-terminal nodes as skipped
|
|
453
|
+
for (const nr of nodeRunsByName.values()) {
|
|
454
|
+
if (nr.status === "running" && nr.signalRunId) {
|
|
455
|
+
await this.signalRunner.cancel(nr.signalRunId);
|
|
456
|
+
}
|
|
457
|
+
if (nr.status === "pending" || nr.status === "running") {
|
|
458
|
+
nr.status = "skipped";
|
|
459
|
+
nr.skipReason = "cancelled";
|
|
460
|
+
nr.completedAt = new Date();
|
|
461
|
+
await this.adapter.updateNodeRun(nr.id, {
|
|
462
|
+
status: "skipped",
|
|
463
|
+
skipReason: "cancelled",
|
|
464
|
+
completedAt: nr.completedAt,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const error = `Node "${failedNodes[0].nodeName}" failed (fail-fast)`;
|
|
469
|
+
bRun.status = "failed";
|
|
470
|
+
bRun.completedAt = new Date();
|
|
471
|
+
bRun.error = error;
|
|
472
|
+
await this.adapter.updateBroadcastRun(bRun.id, {
|
|
473
|
+
status: bRun.status,
|
|
474
|
+
completedAt: bRun.completedAt,
|
|
475
|
+
error,
|
|
476
|
+
});
|
|
477
|
+
this.emit("onBroadcastFailed", { broadcastRun: bRun, error });
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
if (policy === "skip-downstream" || policy === "continue") {
|
|
481
|
+
// Skip downstream nodes whose upstreams have failed
|
|
482
|
+
await this.skipDownstream(definition, nodeRunsByName, bRun);
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Transitively skip pending nodes that have ANY upstream dependency that is
|
|
489
|
+
* failed or was skipped due to an upstream failure (skipReason === "upstream-failed").
|
|
490
|
+
* Guard-skipped nodes (skipReason === "guard") do NOT propagate failure downstream.
|
|
491
|
+
*/
|
|
492
|
+
async skipDownstream(definition, nodeRunsByName, bRun) {
|
|
493
|
+
let changed = true;
|
|
494
|
+
while (changed) {
|
|
495
|
+
changed = false;
|
|
496
|
+
for (const node of definition.nodes) {
|
|
497
|
+
const nr = nodeRunsByName.get(node.name);
|
|
498
|
+
if (!nr || nr.status !== "pending")
|
|
499
|
+
continue;
|
|
500
|
+
if (node.dependsOn.length > 0) {
|
|
501
|
+
// H2: Skip when ANY dep is failed or failure-skipped (not ALL)
|
|
502
|
+
const anyDepFailed = node.dependsOn.some((dep) => {
|
|
503
|
+
const depRun = nodeRunsByName.get(dep);
|
|
504
|
+
if (!depRun)
|
|
505
|
+
return false;
|
|
506
|
+
if (depRun.status === "failed")
|
|
507
|
+
return true;
|
|
508
|
+
// H3: Only propagate from upstream-failed skips, not guard skips
|
|
509
|
+
return depRun.status === "skipped" && depRun.skipReason === "upstream-failed";
|
|
510
|
+
});
|
|
511
|
+
if (anyDepFailed) {
|
|
512
|
+
// H1: Await adapter writes instead of fire-and-forget
|
|
513
|
+
nr.status = "skipped";
|
|
514
|
+
nr.skipReason = "upstream-failed";
|
|
515
|
+
nr.completedAt = new Date();
|
|
516
|
+
await this.adapter.updateNodeRun(nr.id, {
|
|
517
|
+
status: "skipped",
|
|
518
|
+
skipReason: "upstream-failed",
|
|
519
|
+
completedAt: nr.completedAt,
|
|
520
|
+
});
|
|
521
|
+
this.emit("onNodeSkipped", {
|
|
522
|
+
broadcastRun: bRun,
|
|
523
|
+
nodeRun: nr,
|
|
524
|
+
reason: "Upstream dependency failed",
|
|
525
|
+
});
|
|
526
|
+
changed = true;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// ─── Trigger ready nodes ───────────────────────────────────────────
|
|
533
|
+
async triggerReadyNodes(bRun, definition, nodeRunsByName) {
|
|
534
|
+
for (const node of definition.nodes) {
|
|
535
|
+
const nodeRun = nodeRunsByName.get(node.name);
|
|
536
|
+
if (!nodeRun || nodeRun.status !== "pending")
|
|
537
|
+
continue;
|
|
538
|
+
// H3: Dep is ready if completed OR guard-skipped. Failure-skipped deps are NOT ready
|
|
539
|
+
// (those should have been handled by skipDownstream already).
|
|
540
|
+
const depsReady = node.dependsOn.every((dep) => {
|
|
541
|
+
const depRun = nodeRunsByName.get(dep);
|
|
542
|
+
if (!depRun)
|
|
543
|
+
return false;
|
|
544
|
+
if (depRun.status === "completed")
|
|
545
|
+
return true;
|
|
546
|
+
if (depRun.status === "skipped" && depRun.skipReason === "guard")
|
|
547
|
+
return true;
|
|
548
|
+
return false;
|
|
549
|
+
});
|
|
550
|
+
if (!depsReady)
|
|
551
|
+
continue;
|
|
552
|
+
// Build upstream outputs map (always keyed by dep name, even for root)
|
|
553
|
+
const upstreamOutputs = {};
|
|
554
|
+
for (const dep of node.dependsOn) {
|
|
555
|
+
const depRun = nodeRunsByName.get(dep);
|
|
556
|
+
upstreamOutputs[dep] = depRun.output ? JSON.parse(depRun.output) : undefined;
|
|
557
|
+
}
|
|
558
|
+
// M10: when guard always receives upstreamOutputs (broadcast input for root nodes)
|
|
559
|
+
const guardInput = node.dependsOn.length === 0
|
|
560
|
+
? JSON.parse(bRun.input)
|
|
561
|
+
: upstreamOutputs;
|
|
562
|
+
// Evaluate `when` guard — M1: wrap in try/catch
|
|
563
|
+
if (node.when) {
|
|
564
|
+
let guardResult;
|
|
565
|
+
try {
|
|
566
|
+
guardResult = node.when(guardInput);
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
570
|
+
nodeRun.status = "failed";
|
|
571
|
+
nodeRun.error = error;
|
|
572
|
+
nodeRun.completedAt = new Date();
|
|
573
|
+
await this.adapter.updateNodeRun(nodeRun.id, {
|
|
574
|
+
status: "failed",
|
|
575
|
+
error,
|
|
576
|
+
completedAt: nodeRun.completedAt,
|
|
577
|
+
});
|
|
578
|
+
this.emit("onNodeFailed", { broadcastRun: bRun, nodeRun, error });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (!guardResult) {
|
|
582
|
+
nodeRun.status = "skipped";
|
|
583
|
+
nodeRun.skipReason = "guard";
|
|
584
|
+
nodeRun.completedAt = new Date();
|
|
585
|
+
await this.adapter.updateNodeRun(nodeRun.id, {
|
|
586
|
+
status: "skipped",
|
|
587
|
+
skipReason: "guard",
|
|
588
|
+
completedAt: nodeRun.completedAt,
|
|
589
|
+
});
|
|
590
|
+
this.emit("onNodeSkipped", {
|
|
591
|
+
broadcastRun: bRun,
|
|
592
|
+
nodeRun,
|
|
593
|
+
reason: "Guard \"when\" returned false",
|
|
594
|
+
});
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Compute input for this node's signal — M1: wrap map in try/catch
|
|
599
|
+
let nodeInput;
|
|
600
|
+
if (node.dependsOn.length === 0) {
|
|
601
|
+
nodeInput = JSON.parse(bRun.input);
|
|
602
|
+
}
|
|
603
|
+
else if (node.map) {
|
|
604
|
+
try {
|
|
605
|
+
nodeInput = node.map(upstreamOutputs);
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
609
|
+
nodeRun.status = "failed";
|
|
610
|
+
nodeRun.error = error;
|
|
611
|
+
nodeRun.completedAt = new Date();
|
|
612
|
+
await this.adapter.updateNodeRun(nodeRun.id, {
|
|
613
|
+
status: "failed",
|
|
614
|
+
error,
|
|
615
|
+
completedAt: nodeRun.completedAt,
|
|
616
|
+
});
|
|
617
|
+
this.emit("onNodeFailed", { broadcastRun: bRun, nodeRun, error });
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
else if (node.dependsOn.length === 1) {
|
|
622
|
+
nodeInput = upstreamOutputs[node.dependsOn[0]];
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
nodeInput = upstreamOutputs;
|
|
626
|
+
}
|
|
627
|
+
// H1: Use signal.trigger() for Zod input validation instead of writing directly
|
|
628
|
+
let signalRunId;
|
|
629
|
+
try {
|
|
630
|
+
signalRunId = await node.signal.trigger(nodeInput);
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
634
|
+
nodeRun.status = "failed";
|
|
635
|
+
nodeRun.error = error;
|
|
636
|
+
nodeRun.completedAt = new Date();
|
|
637
|
+
await this.adapter.updateNodeRun(nodeRun.id, {
|
|
638
|
+
status: "failed",
|
|
639
|
+
error,
|
|
640
|
+
completedAt: nodeRun.completedAt,
|
|
641
|
+
});
|
|
642
|
+
this.emit("onNodeFailed", { broadcastRun: bRun, nodeRun, error });
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
// Update node run
|
|
646
|
+
await this.adapter.updateNodeRun(nodeRun.id, {
|
|
647
|
+
signalRunId,
|
|
648
|
+
input: JSON.stringify(nodeInput),
|
|
649
|
+
status: "running",
|
|
650
|
+
startedAt: new Date(),
|
|
651
|
+
});
|
|
652
|
+
nodeRun.status = "running";
|
|
653
|
+
nodeRun.signalRunId = signalRunId;
|
|
654
|
+
nodeRun.input = JSON.stringify(nodeInput);
|
|
655
|
+
this.emit("onNodeTriggered", { broadcastRun: bRun, nodeRun });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=broadcast-runner.js.map
|