@smithers-orchestrator/scheduler 0.16.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +190 -0
  3. package/src/ApprovalResolution.ts +7 -0
  4. package/src/CachePolicy.ts +8 -0
  5. package/src/ContinuationRequest.ts +3 -0
  6. package/src/ContinueAsNewTransition.ts +9 -0
  7. package/src/EngineDecision.ts +19 -0
  8. package/src/PlanNode.ts +32 -0
  9. package/src/RalphMeta.ts +7 -0
  10. package/src/RalphState.ts +4 -0
  11. package/src/RalphStateMap.ts +3 -0
  12. package/src/ReadonlyTaskStateMap.ts +3 -0
  13. package/src/RenderContext.ts +14 -0
  14. package/src/RetryPolicy.ts +6 -0
  15. package/src/RetryWaitMap.ts +1 -0
  16. package/src/RunResult.ts +15 -0
  17. package/src/ScheduleResult.ts +15 -0
  18. package/src/ScheduleSnapshot.ts +8 -0
  19. package/src/Scheduler.js +28 -0
  20. package/src/SchedulerLive.js +8 -0
  21. package/src/SmithersWorkflowOptions.ts +43 -0
  22. package/src/TaskFailure.ts +5 -0
  23. package/src/TaskOutput.ts +9 -0
  24. package/src/TaskRecord.ts +10 -0
  25. package/src/TaskState.ts +10 -0
  26. package/src/TaskStateMap.ts +3 -0
  27. package/src/TokenUsage.ts +9 -0
  28. package/src/WaitReason.ts +8 -0
  29. package/src/WorkflowSession.js +10 -0
  30. package/src/WorkflowSessionLive.js +6 -0
  31. package/src/WorkflowSessionOptions.ts +10 -0
  32. package/src/WorkflowSessionService.ts +52 -0
  33. package/src/buildPlanTree.js +273 -0
  34. package/src/buildStateKey.js +8 -0
  35. package/src/cloneTaskStateMap.js +10 -0
  36. package/src/computeRetryDelayMs.js +14 -0
  37. package/src/index.d.ts +437 -0
  38. package/src/index.js +53 -0
  39. package/src/isTerminalState.js +15 -0
  40. package/src/makeWorkflowSession.js +723 -0
  41. package/src/nowMs.js +6 -0
  42. package/src/parseStateKey.js +15 -0
  43. package/src/retryPolicyToSchedule.js +26 -0
  44. package/src/retryScheduleDelayMs.js +23 -0
  45. package/src/scheduleTasks.js +330 -0
@@ -0,0 +1,723 @@
1
+ import { Effect } from "effect";
2
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
3
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
4
+ import { buildPlanTree } from "./buildPlanTree.js";
5
+ import { buildStateKey } from "./buildStateKey.js";
6
+ import { cloneTaskStateMap } from "./cloneTaskStateMap.js";
7
+ import { parseStateKey } from "./parseStateKey.js";
8
+ import { scheduleTasks } from "./scheduleTasks.js";
9
+ /** @typedef {import("./ApprovalResolution.ts").ApprovalResolution} ApprovalResolution */
10
+ /** @typedef {import("./EngineDecision.ts").EngineDecision} EngineDecision */
11
+ /** @typedef {import("./RenderContext.ts").RenderContext} RenderContext */
12
+ /** @typedef {import("./RunResult.ts").RunResult} RunResult */
13
+ /** @typedef {import("./ScheduleResult.ts").ScheduleResult} ScheduleResult */
14
+ /** @typedef {import("./TaskOutput.ts").TaskOutput} TaskOutput */
15
+ /** @typedef {import("./WaitReason.ts").WaitReason} WaitReason */
16
+
17
+ /** @typedef {import("./WorkflowSessionOptions.ts").WorkflowSessionOptions} WorkflowSessionOptions */
18
+ /** @typedef {import("./WorkflowSessionService.ts").WorkflowSessionService} WorkflowSessionService */
19
+
20
+ /**
21
+ * @returns {string}
22
+ */
23
+ function defaultRunId() {
24
+ return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
25
+ }
26
+ /**
27
+ * @param {readonly TaskDescriptor[]} tasks
28
+ * @returns {Map<string, TaskDescriptor>}
29
+ */
30
+ function descriptorMap(tasks) {
31
+ const map = new Map();
32
+ for (const task of tasks) {
33
+ map.set(task.nodeId, task);
34
+ }
35
+ return map;
36
+ }
37
+ /**
38
+ * @param {SessionState} state
39
+ * @param {string} nodeId
40
+ * @param {number} [iteration]
41
+ * @returns {TaskDescriptor | undefined}
42
+ */
43
+ function findDescriptor(state, nodeId, iteration) {
44
+ const descriptor = state.descriptors.get(nodeId);
45
+ if (descriptor && (iteration == null || descriptor.iteration === iteration)) {
46
+ return descriptor;
47
+ }
48
+ return [...state.descriptors.values()].find((candidate) => candidate.nodeId === nodeId &&
49
+ (iteration == null || candidate.iteration === iteration));
50
+ }
51
+ /**
52
+ * @param {Pick<TaskDescriptor, "nodeId" | "iteration">} descriptor
53
+ */
54
+ function stateKeyFor(descriptor) {
55
+ return buildStateKey(descriptor.nodeId, descriptor.iteration);
56
+ }
57
+ /**
58
+ * @param {WorkflowGraph} graph
59
+ * @returns {string}
60
+ */
61
+ function mountedSignature(graph) {
62
+ return [...graph.mountedTaskIds].sort().join("\n");
63
+ }
64
+ /**
65
+ * @param {SessionState} state
66
+ * @param {number} [iterationOverride]
67
+ * @returns {RenderContext}
68
+ */
69
+ function renderContext(state, iterationOverride) {
70
+ const ralphIterations = [...state.ralphState.values()].map((value) => value.iteration);
71
+ return {
72
+ runId: state.runId,
73
+ graph: state.graph,
74
+ iteration: iterationOverride ??
75
+ (ralphIterations.length === 1 ? ralphIterations[0] : 0),
76
+ taskStates: cloneTaskStateMap(state.states),
77
+ outputs: new Map(state.outputs),
78
+ ralphIterations: new Map([...state.ralphState.entries()].map(([id, value]) => [id, value.iteration])),
79
+ };
80
+ }
81
+ /**
82
+ * @param {SessionState} state
83
+ * @param {number} currentTimeMs
84
+ * @returns {WaitReason | undefined}
85
+ */
86
+ function findWaitingReason(state, currentTimeMs) {
87
+ for (const descriptor of state.descriptors.values()) {
88
+ const taskState = state.states.get(stateKeyFor(descriptor));
89
+ if (taskState === "waiting-approval") {
90
+ return { _tag: "Approval", nodeId: descriptor.nodeId };
91
+ }
92
+ if (taskState === "waiting-event") {
93
+ const eventName = typeof descriptor.meta?.__eventName === "string"
94
+ ? descriptor.meta.__eventName
95
+ : "";
96
+ return { _tag: "Event", eventName };
97
+ }
98
+ if (taskState === "waiting-timer") {
99
+ return {
100
+ _tag: "Timer",
101
+ resumeAtMs: timerResumeAtMs(descriptor, currentTimeMs),
102
+ };
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+ /**
108
+ * @param {TaskDescriptor} descriptor
109
+ * @param {number} nowMs
110
+ * @returns {number}
111
+ */
112
+ function timerResumeAtMs(descriptor, nowMs) {
113
+ const until = descriptor.meta?.__timerUntil;
114
+ if (typeof until === "string" && until.length > 0) {
115
+ const parsed = Date.parse(until);
116
+ if (Number.isFinite(parsed))
117
+ return parsed;
118
+ }
119
+ const duration = descriptor.meta?.__timerDuration;
120
+ if (typeof duration === "string") {
121
+ const ms = parseDurationMs(duration);
122
+ if (ms != null)
123
+ return nowMs + ms;
124
+ }
125
+ return nowMs;
126
+ }
127
+ /**
128
+ * @param {string} value
129
+ * @returns {number | null}
130
+ */
131
+ function parseDurationMs(value) {
132
+ const trimmed = value.trim();
133
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed);
134
+ if (!match)
135
+ return null;
136
+ const amount = Number(match[1]);
137
+ const unit = match[2] ?? "ms";
138
+ if (!Number.isFinite(amount))
139
+ return null;
140
+ switch (unit) {
141
+ case "h":
142
+ return amount * 60 * 60 * 1000;
143
+ case "m":
144
+ return amount * 60 * 1000;
145
+ case "s":
146
+ return amount * 1000;
147
+ case "ms":
148
+ default:
149
+ return amount;
150
+ }
151
+ }
152
+ /**
153
+ * @param {TaskDescriptor} descriptor
154
+ * @param {number} failureCount
155
+ * @returns {number}
156
+ */
157
+ function retryDelayMs(descriptor, failureCount) {
158
+ const policy = descriptor.retryPolicy;
159
+ if (!policy)
160
+ return 0;
161
+ const initial = policy.initialDelayMs ?? 0;
162
+ if (policy.backoff === "exponential") {
163
+ const multiplier = policy.multiplier ?? 2;
164
+ const computed = initial * Math.pow(multiplier, Math.max(0, failureCount - 1));
165
+ return Math.min(policy.maxDelayMs ?? computed, computed);
166
+ }
167
+ if (policy.backoff === "linear") {
168
+ const computed = initial * Math.max(1, failureCount);
169
+ return Math.min(policy.maxDelayMs ?? computed, computed);
170
+ }
171
+ return initial;
172
+ }
173
+ /**
174
+ * @param {TaskDescriptor} descriptor
175
+ * @param {unknown} error
176
+ * @returns {boolean}
177
+ */
178
+ function isRetryableFailure(descriptor, error) {
179
+ const payloadCode = error && typeof error === "object" && typeof error.code === "string"
180
+ ? error.code
181
+ : undefined;
182
+ const normalized = toSmithersError(error);
183
+ const code = payloadCode ?? normalized.code;
184
+ const isAgentTask = Boolean(descriptor.agent);
185
+ const nonRetryableComputeCodes = new Set([
186
+ "INVALID_OUTPUT",
187
+ "HEARTBEAT_PAYLOAD_NOT_JSON_SERIALIZABLE",
188
+ "HEARTBEAT_PAYLOAD_TOO_LARGE",
189
+ ]);
190
+ if (!isAgentTask && nonRetryableComputeCodes.has(code)) {
191
+ return false;
192
+ }
193
+ return true;
194
+ }
195
+ /**
196
+ * @param {unknown} error
197
+ * @param {string} label
198
+ * @returns {EngineDecision}
199
+ */
200
+ function failedDecision(error, label) {
201
+ return {
202
+ _tag: "Failed",
203
+ error: toSmithersError(error, label, { code: "SESSION_ERROR" }),
204
+ };
205
+ }
206
+ /**
207
+ * @param {WorkflowSessionOptions} [options]
208
+ * @returns {WorkflowSessionService}
209
+ */
210
+ export function makeWorkflowSession(options = {}) {
211
+ const nowMs = options.nowMs ?? (() => Date.now());
212
+ const state = {
213
+ runId: options.runId ?? defaultRunId(),
214
+ graph: null,
215
+ plan: null,
216
+ descriptors: new Map(),
217
+ states: new Map(),
218
+ outputs: new Map(),
219
+ failures: new Map(),
220
+ retryCounts: new Map(),
221
+ retryWait: new Map(),
222
+ approvals: new Set(),
223
+ ralphState: new Map(options.initialRalphState ?? []),
224
+ schedule: null,
225
+ cancelled: false,
226
+ lastMountedSignature: null,
227
+ };
228
+ /**
229
+ * @param {Pick<TaskOutput, "nodeId" | "iteration">} output
230
+ * @returns {string}
231
+ */
232
+ function outputKey(output) {
233
+ return buildStateKey(output.nodeId, output.iteration);
234
+ }
235
+ /**
236
+ * @param {RunResult["status"]} [status]
237
+ * @returns {EngineDecision}
238
+ */
239
+ function finishedResult(status = "finished") {
240
+ return {
241
+ _tag: "Finished",
242
+ result: {
243
+ runId: state.runId,
244
+ status,
245
+ output: [...state.outputs.values()].at(-1)?.output,
246
+ },
247
+ };
248
+ }
249
+ /**
250
+ * @returns {ScheduleResult}
251
+ */
252
+ function computeSchedule() {
253
+ const result = scheduleTasks(state.plan, state.states, state.descriptors, state.ralphState, state.retryWait, nowMs());
254
+ state.schedule = {
255
+ plan: state.plan,
256
+ result,
257
+ computedAtMs: nowMs(),
258
+ };
259
+ return result;
260
+ }
261
+ /**
262
+ * @param {WorkflowGraph} graph
263
+ * @param {{ readonly pruneUnmounted?: boolean }} [opts]
264
+ */
265
+ function markGraph(graph, opts = {}) {
266
+ state.graph = graph;
267
+ state.descriptors = descriptorMap(graph.tasks);
268
+ const { plan, ralphs } = buildPlanTree(graph.xml, state.ralphState);
269
+ state.plan = plan;
270
+ if (opts.pruneUnmounted) {
271
+ const mounted = new Set(graph.mountedTaskIds);
272
+ for (const [key, taskState] of [...state.states.entries()]) {
273
+ if (mounted.has(key))
274
+ continue;
275
+ if (taskState === "in-progress") {
276
+ state.states.set(key, "cancelled");
277
+ }
278
+ else {
279
+ state.states.delete(key);
280
+ }
281
+ state.retryWait.delete(key);
282
+ }
283
+ }
284
+ for (const ralph of ralphs) {
285
+ const existing = state.ralphState.get(ralph.id);
286
+ if (ralph.until) {
287
+ state.ralphState.set(ralph.id, {
288
+ iteration: existing?.iteration ?? 0,
289
+ done: true,
290
+ });
291
+ }
292
+ else if (!existing) {
293
+ state.ralphState.set(ralph.id, { iteration: 0, done: false });
294
+ }
295
+ }
296
+ for (const task of graph.tasks) {
297
+ const key = stateKeyFor(task);
298
+ if (!state.states.has(key)) {
299
+ state.states.set(key, "pending");
300
+ }
301
+ }
302
+ }
303
+ /**
304
+ * @param {TaskOutput} output
305
+ */
306
+ function markTaskFinished(output) {
307
+ const key = outputKey(output);
308
+ state.states.set(key, "finished");
309
+ state.outputs.set(key, output);
310
+ state.retryWait.delete(key);
311
+ }
312
+ /**
313
+ * @param {number} [iteration]
314
+ * @returns {EngineDecision}
315
+ */
316
+ function decideAfterOutputChange(iteration) {
317
+ if (options.requireRerenderOnOutputChange) {
318
+ return { _tag: "ReRender", context: renderContext(state, iteration) };
319
+ }
320
+ return decide();
321
+ }
322
+ /**
323
+ * @param {TaskDescriptor} descriptor
324
+ * @param {ApprovalResolution} resolution
325
+ */
326
+ function applyApprovalResolution(descriptor, resolution) {
327
+ const key = stateKeyFor(descriptor);
328
+ if (resolution.approved) {
329
+ state.approvals.add(key);
330
+ state.states.set(key, "pending");
331
+ }
332
+ else if (descriptor.approvalOnDeny === "skip") {
333
+ state.states.set(key, "skipped");
334
+ }
335
+ else if (descriptor.approvalOnDeny === "continue") {
336
+ state.states.set(key, "finished");
337
+ state.outputs.set(key, {
338
+ nodeId: descriptor.nodeId,
339
+ iteration: descriptor.iteration,
340
+ output: resolution,
341
+ });
342
+ }
343
+ else {
344
+ state.states.set(key, "failed");
345
+ state.failures.set(key, resolution);
346
+ }
347
+ }
348
+ /**
349
+ * @param {TaskDescriptor} descriptor
350
+ * @param {unknown} error
351
+ * @returns {EngineDecision}
352
+ */
353
+ function applyFailure(descriptor, error) {
354
+ const key = stateKeyFor(descriptor);
355
+ const failureCount = (state.retryCounts.get(key) ?? 0) + 1;
356
+ state.retryCounts.set(key, failureCount);
357
+ const retryable = isRetryableFailure(descriptor, error);
358
+ const canRetry = retryable &&
359
+ (descriptor.retries === Infinity || failureCount <= descriptor.retries);
360
+ if (canRetry) {
361
+ const delay = retryDelayMs(descriptor, failureCount);
362
+ state.states.set(key, "pending");
363
+ if (delay > 0) {
364
+ state.retryWait.set(key, nowMs() + delay);
365
+ }
366
+ else {
367
+ state.retryWait.delete(key);
368
+ }
369
+ return decide();
370
+ }
371
+ state.states.set(key, "failed");
372
+ state.failures.set(key, error);
373
+ return decide();
374
+ }
375
+ function ralphStatePayload() {
376
+ return {
377
+ ralphState: Object.fromEntries([...state.ralphState.entries()].map(([id, value]) => [
378
+ id,
379
+ { iteration: value.iteration, done: value.done },
380
+ ])),
381
+ };
382
+ }
383
+ /**
384
+ * @returns {EngineDecision}
385
+ */
386
+ function decide(depth = 0) {
387
+ if (depth > 10) {
388
+ return { _tag: "Wait", reason: { _tag: "ExternalTrigger" } };
389
+ }
390
+ if (state.cancelled) {
391
+ return finishedResult("cancelled");
392
+ }
393
+ if (!state.graph) {
394
+ return { _tag: "Wait", reason: { _tag: "ExternalTrigger" } };
395
+ }
396
+ for (const [key, taskState] of state.states) {
397
+ const parsed = parseStateKey(key);
398
+ const descriptor = findDescriptor(state, parsed.nodeId, parsed.iteration);
399
+ if (taskState === "failed" && !descriptor?.continueOnFail) {
400
+ return {
401
+ _tag: "Failed",
402
+ error: new SmithersError("SESSION_ERROR", `Task failed: ${descriptor?.nodeId ?? key}`, { key }, state.failures.get(key)),
403
+ };
404
+ }
405
+ }
406
+ const schedule = computeSchedule();
407
+ if (schedule.fatalError) {
408
+ return {
409
+ _tag: "Failed",
410
+ error: new SmithersError("SCHEDULER_ERROR", schedule.fatalError),
411
+ };
412
+ }
413
+ if (schedule.continuation) {
414
+ return {
415
+ _tag: "ContinueAsNew",
416
+ transition: {
417
+ reason: "explicit",
418
+ stateJson: schedule.continuation.stateJson,
419
+ },
420
+ };
421
+ }
422
+ const executable = [];
423
+ let waitReason;
424
+ let changed = false;
425
+ for (const task of schedule.runnable) {
426
+ const key = stateKeyFor(task);
427
+ if (task.skipIf) {
428
+ state.states.set(key, "skipped");
429
+ changed = true;
430
+ continue;
431
+ }
432
+ if (task.needsApproval && !state.approvals.has(key)) {
433
+ state.states.set(key, "waiting-approval");
434
+ changed = true;
435
+ if (task.waitAsync) {
436
+ continue;
437
+ }
438
+ waitReason ??= { _tag: "Approval", nodeId: task.nodeId };
439
+ continue;
440
+ }
441
+ if (task.meta?.__waitForEvent) {
442
+ state.states.set(key, "waiting-event");
443
+ changed = true;
444
+ if (task.waitAsync) {
445
+ continue;
446
+ }
447
+ waitReason ??= {
448
+ _tag: "Event",
449
+ eventName: typeof task.meta.__eventName === "string" ? task.meta.__eventName : "",
450
+ };
451
+ continue;
452
+ }
453
+ if (task.meta?.__timer) {
454
+ const resumeAtMs = timerResumeAtMs(task, nowMs());
455
+ state.states.set(key, "waiting-timer");
456
+ waitReason ??= { _tag: "Timer", resumeAtMs };
457
+ changed = true;
458
+ continue;
459
+ }
460
+ state.states.set(key, "in-progress");
461
+ executable.push(task);
462
+ changed = true;
463
+ }
464
+ if (executable.length > 0) {
465
+ return { _tag: "Execute", tasks: executable };
466
+ }
467
+ if (waitReason) {
468
+ return { _tag: "Wait", reason: waitReason };
469
+ }
470
+ if (changed) {
471
+ return decide(depth + 1);
472
+ }
473
+ const existingWait = findWaitingReason(state, nowMs());
474
+ if (existingWait) {
475
+ return { _tag: "Wait", reason: existingWait };
476
+ }
477
+ if (schedule.pendingExists) {
478
+ if (schedule.nextRetryAtMs != null) {
479
+ return {
480
+ _tag: "Wait",
481
+ reason: {
482
+ _tag: "RetryBackoff",
483
+ waitMs: Math.max(0, schedule.nextRetryAtMs - nowMs()),
484
+ },
485
+ };
486
+ }
487
+ return { _tag: "Wait", reason: { _tag: "ExternalTrigger" } };
488
+ }
489
+ if ([...state.states.values()].some((taskState) => taskState === "in-progress")) {
490
+ return { _tag: "Wait", reason: { _tag: "ExternalTrigger" } };
491
+ }
492
+ if (schedule.readyRalphs.length > 0) {
493
+ for (const ralph of schedule.readyRalphs) {
494
+ const current = state.ralphState.get(ralph.id) ?? {
495
+ iteration: 0,
496
+ done: false,
497
+ };
498
+ if (ralph.until) {
499
+ state.ralphState.set(ralph.id, { ...current, done: true });
500
+ continue;
501
+ }
502
+ const nextIteration = current.iteration + 1;
503
+ if (nextIteration >= ralph.maxIterations) {
504
+ if (ralph.onMaxReached === "fail") {
505
+ return {
506
+ _tag: "Failed",
507
+ error: new SmithersError("RALPH_MAX_REACHED", `Ralph ${ralph.id} reached maxIterations ${ralph.maxIterations}.`, { ralphId: ralph.id, maxIterations: ralph.maxIterations }),
508
+ };
509
+ }
510
+ state.ralphState.set(ralph.id, { iteration: current.iteration, done: true });
511
+ continue;
512
+ }
513
+ state.ralphState.set(ralph.id, { iteration: nextIteration, done: false });
514
+ if (ralph.continueAsNewEvery != null &&
515
+ ralph.continueAsNewEvery > 0 &&
516
+ nextIteration > 0 &&
517
+ nextIteration % ralph.continueAsNewEvery === 0) {
518
+ return {
519
+ _tag: "ContinueAsNew",
520
+ transition: {
521
+ reason: "loop-threshold",
522
+ iteration: nextIteration,
523
+ statePayload: ralphStatePayload(),
524
+ },
525
+ };
526
+ }
527
+ }
528
+ return { _tag: "ReRender", context: renderContext(state) };
529
+ }
530
+ if (options.requireStableFinish && state.graph) {
531
+ const signature = mountedSignature(state.graph);
532
+ if (state.lastMountedSignature !== signature) {
533
+ state.lastMountedSignature = signature;
534
+ return { _tag: "ReRender", context: renderContext(state) };
535
+ }
536
+ }
537
+ return finishedResult();
538
+ }
539
+ return {
540
+ submitGraph: (graph) => Effect.sync(() => {
541
+ try {
542
+ markGraph(graph);
543
+ return decide();
544
+ }
545
+ catch (error) {
546
+ return failedDecision(error, "submitGraph");
547
+ }
548
+ }),
549
+ taskCompleted: (output) => Effect.sync(() => {
550
+ const descriptor = findDescriptor(state, output.nodeId, output.iteration);
551
+ if (!descriptor) {
552
+ return failedDecision(new SmithersError("NODE_NOT_FOUND", `Unknown task ${output.nodeId}`), "taskCompleted");
553
+ }
554
+ markTaskFinished(output);
555
+ return decideAfterOutputChange(output.iteration);
556
+ }),
557
+ taskFailed: (failure) => Effect.sync(() => {
558
+ const descriptor = findDescriptor(state, failure.nodeId, failure.iteration);
559
+ if (!descriptor) {
560
+ return failedDecision(new SmithersError("NODE_NOT_FOUND", `Unknown task ${failure.nodeId}`), "taskFailed");
561
+ }
562
+ return applyFailure(descriptor, failure.error);
563
+ }),
564
+ approvalResolved: (nodeId, resolution) => Effect.sync(() => {
565
+ const descriptor = findDescriptor(state, nodeId);
566
+ if (!descriptor) {
567
+ return failedDecision(new SmithersError("NODE_NOT_FOUND", `Unknown approval task ${nodeId}`), "approvalResolved");
568
+ }
569
+ applyApprovalResolution(descriptor, resolution);
570
+ return decide();
571
+ }),
572
+ approvalTimedOut: (nodeId) => Effect.sync(() => {
573
+ const descriptor = findDescriptor(state, nodeId);
574
+ if (!descriptor) {
575
+ return failedDecision(new SmithersError("NODE_NOT_FOUND", `Unknown approval task ${nodeId}`), "approvalTimedOut");
576
+ }
577
+ const key = stateKeyFor(descriptor);
578
+ if (state.states.get(key) !== "waiting-approval") {
579
+ return decide();
580
+ }
581
+ applyApprovalResolution(descriptor, {
582
+ approved: false,
583
+ note: "approval timed out",
584
+ });
585
+ if (state.states.get(key) === "failed") {
586
+ state.failures.set(key, new SmithersError("TASK_TIMEOUT", `Approval timed out for ${descriptor.nodeId}`, { nodeId: descriptor.nodeId, iteration: descriptor.iteration }));
587
+ }
588
+ return decide();
589
+ }),
590
+ eventReceived: (eventName, payload, correlationId = null) => Effect.sync(() => {
591
+ for (const descriptor of state.descriptors.values()) {
592
+ const key = stateKeyFor(descriptor);
593
+ const taskState = state.states.get(key);
594
+ const expected = typeof descriptor.meta?.__eventName === "string"
595
+ ? descriptor.meta.__eventName
596
+ : undefined;
597
+ const expectedCorrelation = typeof descriptor.meta?.__correlationId === "string"
598
+ ? descriptor.meta.__correlationId
599
+ : undefined;
600
+ if (taskState === "waiting-event" &&
601
+ (!expected || expected === eventName) &&
602
+ (expectedCorrelation === undefined || expectedCorrelation === correlationId)) {
603
+ state.states.set(key, "finished");
604
+ state.outputs.set(key, {
605
+ nodeId: descriptor.nodeId,
606
+ iteration: descriptor.iteration,
607
+ output: payload,
608
+ });
609
+ }
610
+ }
611
+ return decide();
612
+ }),
613
+ signalReceived: (signalName, payload, correlationId = null) => Effect.sync(() => {
614
+ for (const descriptor of state.descriptors.values()) {
615
+ const key = stateKeyFor(descriptor);
616
+ const taskState = state.states.get(key);
617
+ const expected = typeof descriptor.meta?.__signalName === "string"
618
+ ? descriptor.meta.__signalName
619
+ : typeof descriptor.meta?.__eventName === "string"
620
+ ? descriptor.meta.__eventName
621
+ : undefined;
622
+ const expectedCorrelation = typeof descriptor.meta?.__correlationId === "string"
623
+ ? descriptor.meta.__correlationId
624
+ : undefined;
625
+ if (taskState === "waiting-event" &&
626
+ (!expected || expected === signalName) &&
627
+ (expectedCorrelation === undefined || expectedCorrelation === correlationId)) {
628
+ state.states.set(key, "finished");
629
+ state.outputs.set(key, {
630
+ nodeId: descriptor.nodeId,
631
+ iteration: descriptor.iteration,
632
+ output: payload,
633
+ });
634
+ }
635
+ }
636
+ return decide();
637
+ }),
638
+ timerFired: (nodeId, firedAtMs = nowMs()) => Effect.sync(() => {
639
+ const descriptor = findDescriptor(state, nodeId);
640
+ if (!descriptor) {
641
+ return failedDecision(new SmithersError("NODE_NOT_FOUND", `Unknown timer task ${nodeId}`), "timerFired");
642
+ }
643
+ const key = stateKeyFor(descriptor);
644
+ if (state.states.get(key) !== "waiting-timer" && !descriptor.meta?.__timer) {
645
+ return decide();
646
+ }
647
+ markTaskFinished({
648
+ nodeId: descriptor.nodeId,
649
+ iteration: descriptor.iteration,
650
+ output: { firedAtMs },
651
+ });
652
+ return decideAfterOutputChange(descriptor.iteration);
653
+ }),
654
+ hotReloaded: (graph) => Effect.sync(() => {
655
+ try {
656
+ markGraph(graph, { pruneUnmounted: true });
657
+ state.lastMountedSignature = null;
658
+ return decide();
659
+ }
660
+ catch (error) {
661
+ return failedDecision(error, "hotReloaded");
662
+ }
663
+ }),
664
+ heartbeatTimedOut: (nodeId, iteration, details = {}) => Effect.sync(() => {
665
+ const descriptor = findDescriptor(state, nodeId, iteration);
666
+ if (!descriptor) {
667
+ return failedDecision(new SmithersError("NODE_NOT_FOUND", `Unknown task ${nodeId}`), "heartbeatTimedOut");
668
+ }
669
+ return applyFailure(descriptor, new SmithersError("TASK_HEARTBEAT_TIMEOUT", `Task ${descriptor.nodeId} heartbeat timed out.`, {
670
+ nodeId: descriptor.nodeId,
671
+ iteration: descriptor.iteration,
672
+ timeoutMs: descriptor.heartbeatTimeoutMs,
673
+ ...details,
674
+ }));
675
+ }),
676
+ cacheResolved: (output, _cached) => Effect.sync(() => {
677
+ const descriptor = findDescriptor(state, output.nodeId, output.iteration);
678
+ if (!descriptor) {
679
+ return failedDecision(new SmithersError("NODE_NOT_FOUND", `Unknown cached task ${output.nodeId}`), "cacheResolved");
680
+ }
681
+ markTaskFinished({
682
+ ...output,
683
+ usage: output.usage ?? null,
684
+ output: output.output,
685
+ });
686
+ return decideAfterOutputChange(output.iteration);
687
+ }),
688
+ cacheMissed: (nodeId, iteration) => Effect.sync(() => {
689
+ const descriptor = findDescriptor(state, nodeId, iteration);
690
+ if (!descriptor) {
691
+ return failedDecision(new SmithersError("NODE_NOT_FOUND", `Unknown cached task ${nodeId}`), "cacheMissed");
692
+ }
693
+ state.retryWait.delete(stateKeyFor(descriptor));
694
+ return decide();
695
+ }),
696
+ recoverOrphanedTasks: () => Effect.sync(() => {
697
+ let count = 0;
698
+ for (const [key, taskState] of state.states) {
699
+ if (taskState === "in-progress") {
700
+ state.states.set(key, "pending");
701
+ count += 1;
702
+ }
703
+ }
704
+ const decision = decide();
705
+ if (count > 0 || decision._tag !== "Wait") {
706
+ return decision;
707
+ }
708
+ return { _tag: "Wait", reason: { _tag: "OrphanRecovery", count } };
709
+ }),
710
+ cancelRequested: () => Effect.sync(() => {
711
+ state.cancelled = true;
712
+ for (const [key, taskState] of state.states) {
713
+ if (taskState !== "finished" && taskState !== "failed" && taskState !== "skipped") {
714
+ state.states.set(key, "cancelled");
715
+ }
716
+ }
717
+ return finishedResult("cancelled");
718
+ }),
719
+ getTaskStates: () => Effect.sync(() => cloneTaskStateMap(state.states)),
720
+ getSchedule: () => Effect.sync(() => state.schedule),
721
+ getCurrentGraph: () => Effect.sync(() => state.graph),
722
+ };
723
+ }