@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.
- package/LICENSE +21 -0
- package/package.json +190 -0
- package/src/ApprovalResolution.ts +7 -0
- package/src/CachePolicy.ts +8 -0
- package/src/ContinuationRequest.ts +3 -0
- package/src/ContinueAsNewTransition.ts +9 -0
- package/src/EngineDecision.ts +19 -0
- package/src/PlanNode.ts +32 -0
- package/src/RalphMeta.ts +7 -0
- package/src/RalphState.ts +4 -0
- package/src/RalphStateMap.ts +3 -0
- package/src/ReadonlyTaskStateMap.ts +3 -0
- package/src/RenderContext.ts +14 -0
- package/src/RetryPolicy.ts +6 -0
- package/src/RetryWaitMap.ts +1 -0
- package/src/RunResult.ts +15 -0
- package/src/ScheduleResult.ts +15 -0
- package/src/ScheduleSnapshot.ts +8 -0
- package/src/Scheduler.js +28 -0
- package/src/SchedulerLive.js +8 -0
- package/src/SmithersWorkflowOptions.ts +43 -0
- package/src/TaskFailure.ts +5 -0
- package/src/TaskOutput.ts +9 -0
- package/src/TaskRecord.ts +10 -0
- package/src/TaskState.ts +10 -0
- package/src/TaskStateMap.ts +3 -0
- package/src/TokenUsage.ts +9 -0
- package/src/WaitReason.ts +8 -0
- package/src/WorkflowSession.js +10 -0
- package/src/WorkflowSessionLive.js +6 -0
- package/src/WorkflowSessionOptions.ts +10 -0
- package/src/WorkflowSessionService.ts +52 -0
- package/src/buildPlanTree.js +273 -0
- package/src/buildStateKey.js +8 -0
- package/src/cloneTaskStateMap.js +10 -0
- package/src/computeRetryDelayMs.js +14 -0
- package/src/index.d.ts +437 -0
- package/src/index.js +53 -0
- package/src/isTerminalState.js +15 -0
- package/src/makeWorkflowSession.js +723 -0
- package/src/nowMs.js +6 -0
- package/src/parseStateKey.js +15 -0
- package/src/retryPolicyToSchedule.js +26 -0
- package/src/retryScheduleDelayMs.js +23 -0
- 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
|
+
}
|