@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
package/src/nowMs.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @returns {number}
3
+ */
4
+ export function nowMs() {
5
+ return Date.now();
6
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @param {string} key
3
+ * @returns {{ readonly nodeId: string; readonly iteration: number; }}
4
+ */
5
+ export function parseStateKey(key) {
6
+ const separator = key.lastIndexOf("::");
7
+ if (separator < 0) {
8
+ return { nodeId: key, iteration: 0 };
9
+ }
10
+ const iteration = Number(key.slice(separator + 2));
11
+ return {
12
+ nodeId: key.slice(0, separator),
13
+ iteration: Number.isFinite(iteration) ? iteration : 0,
14
+ };
15
+ }
@@ -0,0 +1,26 @@
1
+ import { Duration, Schedule } from "effect";
2
+ /** @typedef {import("./RetryPolicy.ts").RetryPolicy} RetryPolicy */
3
+ const MAX_RETRY_DELAY_MS = 5 * 60 * 1000;
4
+ /**
5
+ * Convert a RetryPolicy to an Effect Schedule for use with Effect.retry.
6
+ *
7
+ * @param {RetryPolicy} policy
8
+ * @returns {Schedule.Schedule<unknown>}
9
+ */
10
+ export function retryPolicyToSchedule(policy) {
11
+ const base = typeof policy.initialDelayMs === "number"
12
+ ? Math.max(0, Math.floor(policy.initialDelayMs))
13
+ : 0;
14
+ if (base <= 0)
15
+ return Schedule.stop;
16
+ const backoff = policy.backoff ?? "fixed";
17
+ const capDelay = Schedule.modifyDelay((_out, delay) => Duration.min(delay, Duration.millis(MAX_RETRY_DELAY_MS)));
18
+ switch (backoff) {
19
+ case "fixed":
20
+ return capDelay(Schedule.fixed(Duration.millis(base)));
21
+ case "linear":
22
+ return capDelay(Schedule.linear(Duration.millis(base)));
23
+ case "exponential":
24
+ return capDelay(Schedule.exponential(Duration.millis(base)));
25
+ }
26
+ }
@@ -0,0 +1,23 @@
1
+ import { Effect, Schedule, ScheduleDecision, ScheduleIntervals } from "effect";
2
+ /**
3
+ * @param {Schedule.Schedule<unknown>} schedule
4
+ * @param {number} attempt
5
+ * @returns {number}
6
+ */
7
+ export function retryScheduleDelayMs(schedule, attempt) {
8
+ const safeAttempt = Math.max(1, Math.floor(attempt));
9
+ let state = schedule.initial;
10
+ let now = 0;
11
+ let delayMs = 0;
12
+ for (let index = 0; index < safeAttempt; index++) {
13
+ const [nextState, , decision] = Effect.runSync(schedule.step(now, undefined, state));
14
+ if (ScheduleDecision.isDone(decision)) {
15
+ return 0;
16
+ }
17
+ const nextNow = ScheduleIntervals.start(decision.intervals);
18
+ delayMs = Math.max(0, nextNow - now);
19
+ state = nextState;
20
+ now = nextNow;
21
+ }
22
+ return delayMs;
23
+ }
@@ -0,0 +1,330 @@
1
+ import { buildStateKey } from "./buildStateKey.js";
2
+ /** @typedef {import("./TaskState.ts").TaskState} TaskState */
3
+
4
+ /** @typedef {import("./PlanNode.ts").PlanNode} PlanNode */
5
+ /** @typedef {import("./RalphStateMap.ts").RalphStateMap} RalphStateMap */
6
+ /** @typedef {import("./RetryWaitMap.ts").RetryWaitMap} RetryWaitMap */
7
+ /** @typedef {import("./ScheduleResult.ts").ScheduleResult} ScheduleResult */
8
+ /** @typedef {import("@smithers-orchestrator/graph").TaskDescriptor} TaskDescriptor */
9
+ /** @typedef {import("./TaskStateMap.ts").TaskStateMap} TaskStateMap */
10
+
11
+ /**
12
+ * @param {TaskState} state
13
+ * @param {TaskDescriptor} descriptor
14
+ * @returns {boolean}
15
+ */
16
+ function isTerminal(state, descriptor) {
17
+ if (state === "finished" || state === "skipped")
18
+ return true;
19
+ if (state === "failed")
20
+ return descriptor.continueOnFail;
21
+ return false;
22
+ }
23
+ /**
24
+ * @param {TaskState} state
25
+ * @param {TaskDescriptor} descriptor
26
+ * @returns {boolean}
27
+ */
28
+ function isTraversalTerminal(state, descriptor) {
29
+ if (isTerminal(state, descriptor))
30
+ return true;
31
+ return Boolean(descriptor.waitAsync &&
32
+ (state === "waiting-approval" || state === "waiting-event"));
33
+ }
34
+ /**
35
+ * @param {TaskDescriptor} descriptor
36
+ * @param {TaskStateMap} states
37
+ * @param {Map<string, TaskDescriptor>} descriptors
38
+ * @returns {boolean}
39
+ */
40
+ function dependenciesSatisfied(descriptor, states, descriptors) {
41
+ if (!descriptor.dependsOn || descriptor.dependsOn.length === 0)
42
+ return true;
43
+ for (const dependencyId of descriptor.dependsOn) {
44
+ const dependency = descriptors.get(dependencyId);
45
+ if (!dependency)
46
+ return false;
47
+ const state = states.get(buildStateKey(dependency.nodeId, dependency.iteration));
48
+ if (!state || !isTerminal(state, dependency)) {
49
+ return false;
50
+ }
51
+ }
52
+ return true;
53
+ }
54
+ /**
55
+ * @param {PlanNode | null} plan
56
+ * @param {TaskStateMap} states
57
+ * @param {Map<string, TaskDescriptor>} descriptors
58
+ * @param {RalphStateMap} ralphState
59
+ * @param {RetryWaitMap} retryWait
60
+ * @param {number} nowMs
61
+ * @returns {ScheduleResult}
62
+ */
63
+ export function scheduleTasks(plan, states, descriptors, ralphState, retryWait, nowMs) {
64
+ const runnable = [];
65
+ let pendingExists = false;
66
+ let waitingApprovalExists = false;
67
+ let waitingEventExists = false;
68
+ let waitingTimerExists = false;
69
+ const readyRalphs = [];
70
+ let continuation;
71
+ let nextRetryAtMs;
72
+ let fatalError;
73
+ const groupUsage = new Map();
74
+ for (const [stateKey, state] of states) {
75
+ if (state !== "in-progress")
76
+ continue;
77
+ const separator = stateKey.lastIndexOf("::");
78
+ const nodeId = separator >= 0 ? stateKey.slice(0, separator) : stateKey;
79
+ const descriptor = descriptors.get(nodeId);
80
+ if (!descriptor)
81
+ continue;
82
+ const groupId = descriptor.parallelGroupId;
83
+ const cap = descriptor.parallelMaxConcurrency;
84
+ if (groupId && cap != null) {
85
+ groupUsage.set(groupId, (groupUsage.get(groupId) ?? 0) + 1);
86
+ }
87
+ }
88
+ /**
89
+ * @param {PlanNode} node
90
+ * @returns {{ readonly terminal: boolean; readonly failed: boolean }}
91
+ */
92
+ function inspect(node) {
93
+ switch (node.kind) {
94
+ case "task": {
95
+ const descriptor = descriptors.get(node.nodeId);
96
+ if (!descriptor)
97
+ return { terminal: true, failed: false };
98
+ const state = states.get(buildStateKey(descriptor.nodeId, descriptor.iteration)) ??
99
+ "pending";
100
+ const terminal = state === "finished" ||
101
+ state === "skipped" ||
102
+ state === "failed" ||
103
+ Boolean(descriptor.waitAsync &&
104
+ (state === "waiting-approval" || state === "waiting-event"));
105
+ return { terminal, failed: state === "failed" };
106
+ }
107
+ case "sequence":
108
+ case "group": {
109
+ for (const child of node.children) {
110
+ const result = inspect(child);
111
+ if (!result.terminal)
112
+ return { terminal: false, failed: false };
113
+ if (result.failed)
114
+ return { terminal: true, failed: true };
115
+ }
116
+ return { terminal: true, failed: false };
117
+ }
118
+ case "parallel": {
119
+ let terminal = true;
120
+ let failed = false;
121
+ for (const child of node.children) {
122
+ const result = inspect(child);
123
+ if (!result.terminal)
124
+ terminal = false;
125
+ if (result.failed)
126
+ failed = true;
127
+ }
128
+ return { terminal, failed: terminal && failed };
129
+ }
130
+ case "saga": {
131
+ for (const child of node.actionChildren) {
132
+ const result = inspect(child);
133
+ if (!result.terminal)
134
+ return { terminal: false, failed: false };
135
+ if (result.failed)
136
+ return { terminal: true, failed: true };
137
+ }
138
+ return { terminal: true, failed: false };
139
+ }
140
+ case "try-catch-finally": {
141
+ for (const child of node.tryChildren) {
142
+ const result = inspect(child);
143
+ if (!result.terminal)
144
+ return { terminal: false, failed: false };
145
+ if (result.failed)
146
+ return { terminal: true, failed: true };
147
+ }
148
+ return { terminal: true, failed: false };
149
+ }
150
+ default:
151
+ return { terminal: true, failed: false };
152
+ }
153
+ }
154
+ /**
155
+ * @param {readonly PlanNode[]} children
156
+ */
157
+ function walkSequence(children) {
158
+ for (const child of children) {
159
+ const result = walk(child);
160
+ if (!result.terminal)
161
+ return { terminal: false };
162
+ }
163
+ return { terminal: true };
164
+ }
165
+ /**
166
+ * @param {PlanNode} node
167
+ * @returns {{ readonly terminal: boolean }}
168
+ */
169
+ function walk(node) {
170
+ switch (node.kind) {
171
+ case "task": {
172
+ const descriptor = descriptors.get(node.nodeId);
173
+ if (!descriptor)
174
+ return { terminal: true };
175
+ const state = states.get(buildStateKey(descriptor.nodeId, descriptor.iteration)) ??
176
+ "pending";
177
+ if (state === "waiting-approval")
178
+ waitingApprovalExists = true;
179
+ if (state === "waiting-event")
180
+ waitingEventExists = true;
181
+ if (state === "waiting-timer")
182
+ waitingTimerExists = true;
183
+ if (state === "pending" || state === "cancelled")
184
+ pendingExists = true;
185
+ const terminal = isTraversalTerminal(state, descriptor);
186
+ if (!terminal && (state === "pending" || state === "cancelled")) {
187
+ if (!dependenciesSatisfied(descriptor, states, descriptors)) {
188
+ return { terminal };
189
+ }
190
+ const retryAt = retryWait.get(buildStateKey(descriptor.nodeId, descriptor.iteration));
191
+ if (retryAt && retryAt > nowMs) {
192
+ pendingExists = true;
193
+ nextRetryAtMs =
194
+ nextRetryAtMs == null ? retryAt : Math.min(nextRetryAtMs, retryAt);
195
+ return { terminal };
196
+ }
197
+ const groupId = descriptor.parallelGroupId;
198
+ const cap = descriptor.parallelMaxConcurrency;
199
+ if (groupId && cap != null) {
200
+ const used = groupUsage.get(groupId) ?? 0;
201
+ if (used >= cap) {
202
+ return { terminal };
203
+ }
204
+ groupUsage.set(groupId, used + 1);
205
+ }
206
+ runnable.push(descriptor);
207
+ }
208
+ return { terminal };
209
+ }
210
+ case "sequence":
211
+ return walkSequence(node.children);
212
+ case "parallel": {
213
+ let terminal = true;
214
+ for (const child of node.children) {
215
+ const result = walk(child);
216
+ if (!result.terminal)
217
+ terminal = false;
218
+ }
219
+ return { terminal };
220
+ }
221
+ case "ralph": {
222
+ const state = ralphState.get(node.id);
223
+ const done = node.until || state?.done;
224
+ if (done)
225
+ return { terminal: true };
226
+ let terminal = true;
227
+ for (const child of node.children) {
228
+ const result = walk(child);
229
+ if (!result.terminal)
230
+ terminal = false;
231
+ }
232
+ if (terminal) {
233
+ readyRalphs.push({
234
+ id: node.id,
235
+ until: node.until,
236
+ maxIterations: node.maxIterations,
237
+ onMaxReached: node.onMaxReached,
238
+ continueAsNewEvery: node.continueAsNewEvery,
239
+ });
240
+ }
241
+ return { terminal: false };
242
+ }
243
+ case "continue-as-new":
244
+ continuation = { stateJson: node.stateJson };
245
+ return { terminal: false };
246
+ case "saga": {
247
+ let completedActions = 0;
248
+ let failed = false;
249
+ for (const child of node.actionChildren) {
250
+ const status = inspect(child);
251
+ if (!status.terminal)
252
+ return walk(child);
253
+ if (status.failed) {
254
+ failed = true;
255
+ break;
256
+ }
257
+ completedActions += 1;
258
+ }
259
+ if (!failed)
260
+ return { terminal: true };
261
+ if (node.onFailure === "fail") {
262
+ fatalError ??= `Saga ${node.id} failed`;
263
+ return { terminal: true };
264
+ }
265
+ for (let index = completedActions - 1; index >= 0; index -= 1) {
266
+ const compensation = node.compensationChildren[index];
267
+ if (!compensation)
268
+ continue;
269
+ const result = walk(compensation);
270
+ if (!result.terminal)
271
+ return { terminal: false };
272
+ }
273
+ if (node.onFailure === "compensate-and-fail") {
274
+ fatalError ??= `Saga ${node.id} failed`;
275
+ }
276
+ return { terminal: true };
277
+ }
278
+ case "try-catch-finally": {
279
+ let tryFailed = false;
280
+ for (const child of node.tryChildren) {
281
+ const status = inspect(child);
282
+ if (!status.terminal)
283
+ return walk(child);
284
+ if (status.failed) {
285
+ tryFailed = true;
286
+ break;
287
+ }
288
+ }
289
+ if (tryFailed) {
290
+ if (node.catchChildren.length > 0) {
291
+ const catchResult = walkSequence(node.catchChildren);
292
+ if (!catchResult.terminal)
293
+ return catchResult;
294
+ }
295
+ else {
296
+ fatalError ??= `TryCatchFinally ${node.id} failed`;
297
+ }
298
+ }
299
+ const finallyResult = walkSequence(node.finallyChildren);
300
+ if (!finallyResult.terminal)
301
+ return finallyResult;
302
+ return { terminal: true };
303
+ }
304
+ case "group": {
305
+ let terminal = true;
306
+ for (const child of node.children) {
307
+ const result = walk(child);
308
+ if (!result.terminal)
309
+ terminal = false;
310
+ }
311
+ return { terminal };
312
+ }
313
+ default:
314
+ return { terminal: true };
315
+ }
316
+ }
317
+ if (plan)
318
+ walk(plan);
319
+ return {
320
+ runnable,
321
+ pendingExists,
322
+ waitingApprovalExists,
323
+ waitingEventExists,
324
+ waitingTimerExists,
325
+ readyRalphs,
326
+ continuation,
327
+ nextRetryAtMs,
328
+ fatalError,
329
+ };
330
+ }