@ai-digital-factory/state-flow-runtime 0.0.0-stage → 0.1.1
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 +18 -0
- package/README.md +300 -2
- package/dist/index.d.ts +1024 -0
- package/dist/index.js +4405 -0
- package/package.json +45 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,4405 @@
|
|
|
1
|
+
// src/durable/definition.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
// src/durable/errors.ts
|
|
6
|
+
var RuntimeError = class extends Error {
|
|
7
|
+
constructor(message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = new.target.name;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var OwnershipError = class extends RuntimeError {
|
|
13
|
+
};
|
|
14
|
+
var RuntimeClosedError = class extends RuntimeError {
|
|
15
|
+
};
|
|
16
|
+
var ShutdownTimeoutError = class extends RuntimeError {
|
|
17
|
+
};
|
|
18
|
+
var DefinitionError = class extends RuntimeError {
|
|
19
|
+
problems;
|
|
20
|
+
constructor(flowName, problems) {
|
|
21
|
+
super(`Durable flow "${flowName}" is invalid:
|
|
22
|
+
- ${problems.join("\n- ")}`);
|
|
23
|
+
this.problems = problems;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var InputError = class extends RuntimeError {
|
|
27
|
+
};
|
|
28
|
+
var SchemaError = class extends RuntimeError {
|
|
29
|
+
};
|
|
30
|
+
var DefinitionMismatchError = class extends RuntimeError {
|
|
31
|
+
constructor(context) {
|
|
32
|
+
super(context.message);
|
|
33
|
+
this.context = context;
|
|
34
|
+
}
|
|
35
|
+
context;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// src/durable/json.ts
|
|
39
|
+
function isPlainObject(value) {
|
|
40
|
+
const prototype = Object.getPrototypeOf(value);
|
|
41
|
+
return prototype === Object.prototype || prototype === null;
|
|
42
|
+
}
|
|
43
|
+
function findNonJsonValue(value, path = "$") {
|
|
44
|
+
return findNonJson(value, path, /* @__PURE__ */ new Set());
|
|
45
|
+
}
|
|
46
|
+
function findNonJson(value, path, ancestors) {
|
|
47
|
+
if (value === null) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
switch (typeof value) {
|
|
51
|
+
case "string":
|
|
52
|
+
case "boolean":
|
|
53
|
+
return null;
|
|
54
|
+
case "number":
|
|
55
|
+
return Number.isFinite(value) ? null : `${path} is not a finite number`;
|
|
56
|
+
case "object": {
|
|
57
|
+
if (ancestors.has(value)) {
|
|
58
|
+
return `${path} is a circular reference`;
|
|
59
|
+
}
|
|
60
|
+
ancestors.add(value);
|
|
61
|
+
try {
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
64
|
+
if (!(index in value)) {
|
|
65
|
+
return `${path}[${index}] is an empty array slot`;
|
|
66
|
+
}
|
|
67
|
+
const found = findNonJson(
|
|
68
|
+
value[index],
|
|
69
|
+
`${path}[${index}]`,
|
|
70
|
+
ancestors
|
|
71
|
+
);
|
|
72
|
+
if (found) {
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!isPlainObject(value)) {
|
|
79
|
+
return `${path} is not a plain object`;
|
|
80
|
+
}
|
|
81
|
+
for (const key of Object.keys(value)) {
|
|
82
|
+
const entry = value[key];
|
|
83
|
+
if (entry === void 0) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const found = findNonJson(
|
|
87
|
+
entry,
|
|
88
|
+
`${path}[${JSON.stringify(key)}]`,
|
|
89
|
+
ancestors
|
|
90
|
+
);
|
|
91
|
+
if (found) {
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
} finally {
|
|
97
|
+
ancestors.delete(value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
return `${path} is ${typeof value}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function canonicalJson(value) {
|
|
105
|
+
return writeCanonical(value, /* @__PURE__ */ new Set());
|
|
106
|
+
}
|
|
107
|
+
function writeCanonical(value, ancestors) {
|
|
108
|
+
if (value === null || typeof value !== "object") {
|
|
109
|
+
const text = JSON.stringify(value);
|
|
110
|
+
if (text === void 0) {
|
|
111
|
+
throw new TypeError(`Cannot serialize ${typeof value} as JSON`);
|
|
112
|
+
}
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
115
|
+
if (ancestors.has(value)) {
|
|
116
|
+
throw new TypeError("Cannot serialize a circular reference as JSON");
|
|
117
|
+
}
|
|
118
|
+
ancestors.add(value);
|
|
119
|
+
try {
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
return `[${value.map((entry) => writeCanonical(entry ?? null, ancestors)).join(",")}]`;
|
|
122
|
+
}
|
|
123
|
+
const parts = [];
|
|
124
|
+
for (const key of Object.keys(value).sort()) {
|
|
125
|
+
const entry = value[key];
|
|
126
|
+
if (entry !== void 0) {
|
|
127
|
+
parts.push(
|
|
128
|
+
`${JSON.stringify(key)}:${writeCanonical(entry, ancestors)}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return `{${parts.join(",")}}`;
|
|
133
|
+
} finally {
|
|
134
|
+
ancestors.delete(value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function parseJson(text) {
|
|
138
|
+
return text === null ? null : JSON.parse(text);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/durable/definition.ts
|
|
142
|
+
function isBranching(transition) {
|
|
143
|
+
return transition.destinations.length > 1;
|
|
144
|
+
}
|
|
145
|
+
var STRATEGIES = /* @__PURE__ */ new Set([
|
|
146
|
+
"safe-replay",
|
|
147
|
+
"reconcile",
|
|
148
|
+
"manual"
|
|
149
|
+
]);
|
|
150
|
+
var SLOT_EXPORTS = {
|
|
151
|
+
state: { onEnter: "on-enter", onExit: "on-exit", children: "children" },
|
|
152
|
+
transition: { guard: "guard", onTransition: "on-transition" },
|
|
153
|
+
creation: { resolveStartState: "resolve-start-state" }
|
|
154
|
+
};
|
|
155
|
+
function isRecord(value) {
|
|
156
|
+
return typeof value === "object" && value !== null;
|
|
157
|
+
}
|
|
158
|
+
function readCallback(label, kind, value, problems) {
|
|
159
|
+
if (typeof value === "function") {
|
|
160
|
+
problems.push(
|
|
161
|
+
`${label} is a plain function; durable callbacks must declare { run, recovery }`
|
|
162
|
+
);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (!isRecord(value) || typeof value.run !== "function") {
|
|
166
|
+
problems.push(`${label} must be an object with a run function`);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const recovery = value.recovery;
|
|
170
|
+
if (!isRecord(recovery) || typeof recovery.strategy !== "string") {
|
|
171
|
+
problems.push(`${label} must declare recovery.strategy`);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (!STRATEGIES.has(recovery.strategy)) {
|
|
175
|
+
problems.push(
|
|
176
|
+
`${label} has unknown recovery strategy ${JSON.stringify(recovery.strategy)}`
|
|
177
|
+
);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const strategy = recovery.strategy;
|
|
181
|
+
const hasReconcile = recovery.reconcile !== void 0;
|
|
182
|
+
if (strategy === "reconcile" && typeof recovery.reconcile !== "function") {
|
|
183
|
+
problems.push(
|
|
184
|
+
`${label} uses the reconcile strategy without a reconcile function`
|
|
185
|
+
);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (strategy !== "reconcile" && hasReconcile) {
|
|
189
|
+
problems.push(
|
|
190
|
+
`${label} declares a reconcile function with the ${strategy} strategy`
|
|
191
|
+
);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
kind,
|
|
196
|
+
strategy,
|
|
197
|
+
run: value.run,
|
|
198
|
+
reconcile: strategy === "reconcile" ? recovery.reconcile : null
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function readSlot(slotKind, label, value, problems) {
|
|
202
|
+
if (value === null || value === void 0) {
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
if (!isRecord(value)) {
|
|
206
|
+
problems.push(`${label} must be a handler module object`);
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
const exportsForKind = SLOT_EXPORTS[slotKind];
|
|
210
|
+
const result = {};
|
|
211
|
+
for (const key of Object.keys(value)) {
|
|
212
|
+
if (slotKind === "transition" && key === "payloadSchema") continue;
|
|
213
|
+
const callbackKind = exportsForKind[key];
|
|
214
|
+
if (!callbackKind) {
|
|
215
|
+
problems.push(`${label} exports unexpected member "${key}"`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const callback = readCallback(
|
|
219
|
+
`${label}.${key}`,
|
|
220
|
+
callbackKind,
|
|
221
|
+
value[key],
|
|
222
|
+
problems
|
|
223
|
+
);
|
|
224
|
+
if (callback) {
|
|
225
|
+
result[key] = callback;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
function groupKey(parentId, region) {
|
|
231
|
+
return parentId === null ? "" : `${parentId}\0${region ?? ""}`;
|
|
232
|
+
}
|
|
233
|
+
function loadFlowDefinition(module, loading = /* @__PURE__ */ new Set()) {
|
|
234
|
+
const problems = [];
|
|
235
|
+
if (module.execution !== "durable") {
|
|
236
|
+
problems.push(
|
|
237
|
+
"compiled module is not durable; rebuild its handlers package with the current State Flow compiler"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (loading.has(module.flowName)) {
|
|
241
|
+
throw new DefinitionError(module.flowName, [
|
|
242
|
+
`flow "${module.flowName}" places itself through ${[...loading].join(" \u2192 ")}`
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
const statesById = /* @__PURE__ */ new Map();
|
|
246
|
+
const statesByName = /* @__PURE__ */ new Map();
|
|
247
|
+
const childrenSlots = /* @__PURE__ */ new Map();
|
|
248
|
+
for (const state of module.states) {
|
|
249
|
+
const slot = readSlot(
|
|
250
|
+
"state",
|
|
251
|
+
`state "${state.name}"`,
|
|
252
|
+
module.handlers.states[state.id],
|
|
253
|
+
problems
|
|
254
|
+
);
|
|
255
|
+
const final = state.kind === "final";
|
|
256
|
+
if (final && (slot.onEnter || slot.onExit || slot.children)) {
|
|
257
|
+
problems.push(`final state "${state.name}" cannot declare callbacks`);
|
|
258
|
+
}
|
|
259
|
+
if (slot.children) childrenSlots.set(state.id, slot.children);
|
|
260
|
+
const parentId = typeof state.parentId === "string" ? state.parentId : null;
|
|
261
|
+
const definition = {
|
|
262
|
+
id: state.id,
|
|
263
|
+
name: state.name,
|
|
264
|
+
final,
|
|
265
|
+
onEnter: slot.onEnter ?? null,
|
|
266
|
+
onExit: slot.onExit ?? null,
|
|
267
|
+
composition: null,
|
|
268
|
+
parentId,
|
|
269
|
+
region: parentId === null ? null : typeof state.region === "string" ? state.region : ""
|
|
270
|
+
};
|
|
271
|
+
if (statesByName.has(state.name)) {
|
|
272
|
+
problems.push(`state name "${state.name}" is declared more than once`);
|
|
273
|
+
}
|
|
274
|
+
statesById.set(state.id, definition);
|
|
275
|
+
statesByName.set(state.name, definition);
|
|
276
|
+
}
|
|
277
|
+
for (const state of statesById.values()) {
|
|
278
|
+
if (state.parentId !== null && !statesById.has(state.parentId)) {
|
|
279
|
+
problems.push(`state "${state.name}" has an unknown parent`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const transitionsByName = /* @__PURE__ */ new Map();
|
|
283
|
+
const transitionGroups = /* @__PURE__ */ new Map();
|
|
284
|
+
for (const transition of module.transitions) {
|
|
285
|
+
const from = statesById.get(transition.fromId);
|
|
286
|
+
const destinations = (transition.destinations ?? []).map(
|
|
287
|
+
(destination) => statesById.get(destination.toId)
|
|
288
|
+
);
|
|
289
|
+
if (!from || destinations.length === 0 || destinations.some((destination) => destination === void 0)) {
|
|
290
|
+
problems.push(
|
|
291
|
+
destinations.length === 0 ? `transition "${transition.name}" declares no destinations; rebuild its handlers package with the current State Flow compiler` : `transition "${transition.name}" references an unknown state`
|
|
292
|
+
);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (from.final) {
|
|
296
|
+
problems.push(
|
|
297
|
+
`final state "${from.name}" cannot have outgoing transition "${transition.name}"`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (new Set(destinations).size !== destinations.length) {
|
|
301
|
+
problems.push(
|
|
302
|
+
`transition "${transition.name}" declares a destination more than once`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const fromGroup = groupKey(from.parentId, from.region);
|
|
306
|
+
if (destinations.some(
|
|
307
|
+
(destination) => groupKey(destination.parentId, destination.region) !== fromGroup
|
|
308
|
+
)) {
|
|
309
|
+
problems.push(
|
|
310
|
+
`transition "${transition.name}" must target States of its own region`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (transitionsByName.has(transition.name)) {
|
|
314
|
+
problems.push(
|
|
315
|
+
`transition name "${transition.name}" is declared more than once`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const slot = readSlot(
|
|
319
|
+
"transition",
|
|
320
|
+
`transition "${transition.name}"`,
|
|
321
|
+
module.handlers.transitions[transition.name],
|
|
322
|
+
problems
|
|
323
|
+
);
|
|
324
|
+
const payloadSchema = module.payloadSchemas?.[transition.name];
|
|
325
|
+
if (!(payloadSchema instanceof z.ZodType)) {
|
|
326
|
+
problems.push(
|
|
327
|
+
`transition "${transition.name}" must declare a Zod payloadSchema`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (destinations.length > 1 && !slot.onTransition) {
|
|
331
|
+
problems.push(
|
|
332
|
+
`transition "${transition.name}" has several destinations and must implement onTransition to select one`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const trigger = transition.trigger === "done" ? "done" : "dispatch";
|
|
336
|
+
if (transition.trigger !== void 0 && transition.trigger !== "done" && transition.trigger !== "dispatch") {
|
|
337
|
+
problems.push(`transition "${transition.name}" has an unknown trigger`);
|
|
338
|
+
}
|
|
339
|
+
if (transition.childWork !== void 0 && transition.childWork !== "block" && transition.childWork !== "interrupt") {
|
|
340
|
+
problems.push(
|
|
341
|
+
`transition "${transition.name}" has an unknown childWork policy`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const definition = {
|
|
345
|
+
name: transition.name,
|
|
346
|
+
payloadSchema: payloadSchema instanceof z.ZodType ? payloadSchema : z.never(),
|
|
347
|
+
from,
|
|
348
|
+
destinations,
|
|
349
|
+
guard: slot.guard ?? null,
|
|
350
|
+
onTransition: slot.onTransition ?? null,
|
|
351
|
+
childWork: transition.childWork === "interrupt" ? "interrupt" : "block",
|
|
352
|
+
trigger
|
|
353
|
+
};
|
|
354
|
+
transitionsByName.set(transition.name, definition);
|
|
355
|
+
const group = transitionGroups.get(fromGroup) ?? [];
|
|
356
|
+
group.push(definition);
|
|
357
|
+
transitionGroups.set(fromGroup, group);
|
|
358
|
+
}
|
|
359
|
+
const creation = readSlot(
|
|
360
|
+
"creation",
|
|
361
|
+
"creation handler",
|
|
362
|
+
module.handlers.creation,
|
|
363
|
+
problems
|
|
364
|
+
);
|
|
365
|
+
const initialState = statesById.get(module.initialStateId);
|
|
366
|
+
if (!initialState || initialState.parentId !== null) {
|
|
367
|
+
problems.push(`initial state id "${module.initialStateId}" is unknown`);
|
|
368
|
+
}
|
|
369
|
+
const inputSchema = module.inputSchema === void 0 || module.inputSchema === null ? null : module.inputSchema instanceof z.ZodType ? module.inputSchema : (problems.push("inputSchema must be a Zod schema"), null);
|
|
370
|
+
const placed = /* @__PURE__ */ new Map();
|
|
371
|
+
const placedModules = module.placedFlows ?? {};
|
|
372
|
+
const loadPlaced = (flow) => {
|
|
373
|
+
const cached = placed.get(flow);
|
|
374
|
+
if (cached) return cached;
|
|
375
|
+
const placedModule = placedModules[flow];
|
|
376
|
+
if (!isRecord(placedModule)) {
|
|
377
|
+
problems.push(
|
|
378
|
+
`placed flow "${flow}" is missing from the compiled module`
|
|
379
|
+
);
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const loaded = loadFlowDefinition(
|
|
384
|
+
placedModule,
|
|
385
|
+
/* @__PURE__ */ new Set([...loading, module.flowName])
|
|
386
|
+
);
|
|
387
|
+
placed.set(flow, loaded);
|
|
388
|
+
return loaded;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
problems.push(
|
|
391
|
+
error instanceof DefinitionError ? `placed flow "${flow}": ${error.problems.join("; ")}` : `placed flow "${flow}" failed to load: ${String(error)}`
|
|
392
|
+
);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const groups = /* @__PURE__ */ new Map();
|
|
397
|
+
for (const state of statesById.values()) {
|
|
398
|
+
const key = groupKey(state.parentId, state.region);
|
|
399
|
+
const group = groups.get(key) ?? [];
|
|
400
|
+
group.push(state);
|
|
401
|
+
groups.set(key, group);
|
|
402
|
+
}
|
|
403
|
+
const graphs = /* @__PURE__ */ new Map();
|
|
404
|
+
const buildGraph = (key, groupId, initial, resolveStartState) => {
|
|
405
|
+
const graph = {
|
|
406
|
+
key,
|
|
407
|
+
flowName: module.flowName,
|
|
408
|
+
initialState: initial,
|
|
409
|
+
statesByName: new Map(
|
|
410
|
+
(groups.get(groupId) ?? []).map((state) => [state.name, state])
|
|
411
|
+
),
|
|
412
|
+
transitionsByName: new Map(
|
|
413
|
+
(transitionGroups.get(groupId) ?? []).map((transition) => [
|
|
414
|
+
transition.name,
|
|
415
|
+
transition
|
|
416
|
+
])
|
|
417
|
+
),
|
|
418
|
+
resolveStartState,
|
|
419
|
+
inputSchema
|
|
420
|
+
};
|
|
421
|
+
graphs.set(key, graph);
|
|
422
|
+
return graph;
|
|
423
|
+
};
|
|
424
|
+
const compositionIdentity = [];
|
|
425
|
+
const composite = (module.compositions ?? []).length > 0;
|
|
426
|
+
for (const entry of module.compositions ?? []) {
|
|
427
|
+
const state = statesById.get(entry.stateId);
|
|
428
|
+
if (!state) {
|
|
429
|
+
problems.push(
|
|
430
|
+
`composition references unknown state id "${entry.stateId}"`
|
|
431
|
+
);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (state.final) {
|
|
435
|
+
problems.push(`final state "${state.name}" cannot be composite`);
|
|
436
|
+
}
|
|
437
|
+
if (entry.kind !== "compound" && entry.kind !== "parallel") {
|
|
438
|
+
problems.push(`state "${state.name}" has an unknown composition kind`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const kind = entry.kind;
|
|
442
|
+
if (entry.regions.length === 0 || kind === "compound" && entry.regions.length !== 1) {
|
|
443
|
+
problems.push(
|
|
444
|
+
`state "${state.name}" must declare ${kind === "compound" ? "exactly one" : "at least one"} region`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const regions = [];
|
|
448
|
+
const regionIdentity = [];
|
|
449
|
+
let needsChildren = false;
|
|
450
|
+
for (const region of entry.regions) {
|
|
451
|
+
const segment = kind === "compound" ? state.name : `${state.name}:${region.name}`;
|
|
452
|
+
if (region.kind === "inline") {
|
|
453
|
+
const groupId = groupKey(state.id, region.name);
|
|
454
|
+
const initial = typeof region.defaultId === "string" ? statesById.get(region.defaultId) : void 0;
|
|
455
|
+
if (!initial || groupKey(initial.parentId, initial.region) !== groupId) {
|
|
456
|
+
problems.push(
|
|
457
|
+
`region "${region.name}" of state "${state.name}" must default to one of its own States`
|
|
458
|
+
);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const graph = buildGraph(
|
|
462
|
+
`${module.flowName}#${segment}`,
|
|
463
|
+
groupId,
|
|
464
|
+
initial,
|
|
465
|
+
null
|
|
466
|
+
);
|
|
467
|
+
regions.push({
|
|
468
|
+
name: region.name,
|
|
469
|
+
kind: "inline",
|
|
470
|
+
segment,
|
|
471
|
+
graph,
|
|
472
|
+
flowName: null
|
|
473
|
+
});
|
|
474
|
+
regionIdentity.push({
|
|
475
|
+
name: region.name,
|
|
476
|
+
kind: "inline",
|
|
477
|
+
default: initial.id
|
|
478
|
+
});
|
|
479
|
+
} else if ((region.kind === "flow" || region.kind === "list") && typeof region.flow === "string") {
|
|
480
|
+
const child = loadPlaced(region.flow);
|
|
481
|
+
if (!child) continue;
|
|
482
|
+
for (const [key, graph] of child.graphs) {
|
|
483
|
+
graphs.set(key, graph);
|
|
484
|
+
}
|
|
485
|
+
if (region.kind === "list" || child.inputSchema !== null) {
|
|
486
|
+
needsChildren = true;
|
|
487
|
+
}
|
|
488
|
+
regions.push({
|
|
489
|
+
name: region.name,
|
|
490
|
+
kind: region.kind,
|
|
491
|
+
segment,
|
|
492
|
+
graph: child.graphs.get(child.flowName),
|
|
493
|
+
flowName: child.flowName
|
|
494
|
+
});
|
|
495
|
+
regionIdentity.push({
|
|
496
|
+
name: region.name,
|
|
497
|
+
kind: region.kind,
|
|
498
|
+
flow: child.definitionId
|
|
499
|
+
});
|
|
500
|
+
} else {
|
|
501
|
+
problems.push(
|
|
502
|
+
`region "${region.name}" of state "${state.name}" has an unknown kind`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const children = childrenSlots.get(state.id) ?? null;
|
|
507
|
+
if (needsChildren && !children) {
|
|
508
|
+
problems.push(
|
|
509
|
+
`state "${state.name}" must implement children to supply its children's input and FlowList members`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const done = [...transitionsByName.values()].find(
|
|
513
|
+
(transition) => transition.from === state && transition.trigger === "done"
|
|
514
|
+
) ?? null;
|
|
515
|
+
state.composition = { kind, regions, children, done };
|
|
516
|
+
compositionIdentity.push({
|
|
517
|
+
state: state.id,
|
|
518
|
+
kind,
|
|
519
|
+
regions: regionIdentity,
|
|
520
|
+
children: children === null ? null : children.strategy
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
for (const [stateId, slot] of childrenSlots) {
|
|
524
|
+
if (statesById.get(stateId)?.composition === null) {
|
|
525
|
+
problems.push(
|
|
526
|
+
`state "${statesById.get(stateId)?.name}" implements children but has no nested regions`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
void slot;
|
|
530
|
+
}
|
|
531
|
+
for (const transition of transitionsByName.values()) {
|
|
532
|
+
if (transition.trigger === "done" && transition.from.composition === null) {
|
|
533
|
+
problems.push(
|
|
534
|
+
`transition "${transition.name}" fires on completion but "${transition.from.name}" has no nested regions`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (transition.childWork === "interrupt" && transition.from.composition === null) {
|
|
538
|
+
problems.push(
|
|
539
|
+
`transition "${transition.name}" interrupts child work but "${transition.from.name}" has no nested regions`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (problems.length > 0 || !initialState) {
|
|
544
|
+
throw new DefinitionError(module.flowName, problems);
|
|
545
|
+
}
|
|
546
|
+
const rootGraph = buildGraph(
|
|
547
|
+
module.flowName,
|
|
548
|
+
"",
|
|
549
|
+
initialState,
|
|
550
|
+
creation.resolveStartState ?? null
|
|
551
|
+
);
|
|
552
|
+
const strategyOf = (callback) => callback === null ? null : callback.strategy;
|
|
553
|
+
const identitySource = {
|
|
554
|
+
// Persisted identity key: renaming it would change every stored definition identity.
|
|
555
|
+
chartName: module.flowName,
|
|
556
|
+
initialStateId: module.initialStateId,
|
|
557
|
+
// Fields are added only for new graph features, so flows without them keep their identity.
|
|
558
|
+
states: [...statesById.values()].map((state) => ({
|
|
559
|
+
id: state.id,
|
|
560
|
+
name: state.name,
|
|
561
|
+
onEnter: strategyOf(state.onEnter),
|
|
562
|
+
onExit: strategyOf(state.onExit),
|
|
563
|
+
...state.final ? { final: true } : {},
|
|
564
|
+
...state.parentId !== null ? { parent: state.parentId, region: state.region } : {}
|
|
565
|
+
})),
|
|
566
|
+
transitions: [...transitionsByName.values()].map((transition) => ({
|
|
567
|
+
name: transition.name,
|
|
568
|
+
from: transition.from.id,
|
|
569
|
+
...isBranching(transition) ? { destinations: transition.destinations.map((state) => state.id) } : { to: transition.destinations[0].id },
|
|
570
|
+
guard: strategyOf(transition.guard),
|
|
571
|
+
onTransition: strategyOf(transition.onTransition),
|
|
572
|
+
...transition.childWork === "interrupt" ? { childWork: "interrupt" } : {},
|
|
573
|
+
...transition.trigger === "done" ? { trigger: "done" } : {}
|
|
574
|
+
})),
|
|
575
|
+
resolveStartState: strategyOf(creation.resolveStartState ?? null),
|
|
576
|
+
...composite ? { compositions: compositionIdentity } : {}
|
|
577
|
+
};
|
|
578
|
+
const definitionId = `def_${createHash("sha256").update(canonicalJson(identitySource)).digest("hex").slice(0, 32)}`;
|
|
579
|
+
return {
|
|
580
|
+
...rootGraph,
|
|
581
|
+
definitionId,
|
|
582
|
+
graphs,
|
|
583
|
+
composite,
|
|
584
|
+
placed,
|
|
585
|
+
handlerRevision: typeof module.handlerRevision === "string" ? module.handlerRevision : null
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/durable/engine.ts
|
|
590
|
+
import { z as z2 } from "zod";
|
|
591
|
+
|
|
592
|
+
// src/durable/deployment.ts
|
|
593
|
+
import { existsSync } from "node:fs";
|
|
594
|
+
|
|
595
|
+
// src/durable/store.ts
|
|
596
|
+
import { DatabaseSync } from "node:sqlite";
|
|
597
|
+
var SCHEMA_VERSION = 3;
|
|
598
|
+
var MIGRATIONS = {
|
|
599
|
+
// v1 (first durable runtime) lacked per-execution handler revisions (ADR 0076).
|
|
600
|
+
1: "ALTER TABLE executions ADD COLUMN handler_revision TEXT;",
|
|
601
|
+
// v2 → v3: nested scopes and the late-outcome outbox (ADR 0095, 0096). Existing rows are roots.
|
|
602
|
+
2: `
|
|
603
|
+
ALTER TABLE instances ADD COLUMN root_instance_id TEXT;
|
|
604
|
+
ALTER TABLE instances ADD COLUMN parent_instance_id TEXT;
|
|
605
|
+
ALTER TABLE instances ADD COLUMN scope_path TEXT;
|
|
606
|
+
ALTER TABLE instances ADD COLUMN segment TEXT;
|
|
607
|
+
ALTER TABLE instances ADD COLUMN graph TEXT;
|
|
608
|
+
ALTER TABLE instances ADD COLUMN activation_id TEXT;
|
|
609
|
+
ALTER TABLE instances ADD COLUMN member_key TEXT;
|
|
610
|
+
ALTER TABLE instances ADD COLUMN input_json TEXT;
|
|
611
|
+
ALTER TABLE instances ADD COLUMN position INTEGER;
|
|
612
|
+
ALTER TABLE instances ADD COLUMN active INTEGER NOT NULL DEFAULT 1;
|
|
613
|
+
ALTER TABLE instances ADD COLUMN exit_reason TEXT;
|
|
614
|
+
ALTER TABLE instances ADD COLUMN children_activation_id TEXT;
|
|
615
|
+
CREATE INDEX instances_by_root ON instances (root_instance_id);
|
|
616
|
+
CREATE INDEX instances_by_parent ON instances (parent_instance_id);
|
|
617
|
+
CREATE TABLE late_outcomes (
|
|
618
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
619
|
+
root_instance_id TEXT NOT NULL,
|
|
620
|
+
scope_instance_id TEXT NOT NULL,
|
|
621
|
+
scope_path TEXT NOT NULL,
|
|
622
|
+
activation_id TEXT NOT NULL,
|
|
623
|
+
member_key TEXT,
|
|
624
|
+
operation_id TEXT NOT NULL,
|
|
625
|
+
callback_kind TEXT NOT NULL,
|
|
626
|
+
callback_target TEXT,
|
|
627
|
+
source TEXT NOT NULL,
|
|
628
|
+
result_json TEXT NOT NULL,
|
|
629
|
+
recorded_at TEXT NOT NULL,
|
|
630
|
+
acknowledged_at TEXT
|
|
631
|
+
);
|
|
632
|
+
CREATE INDEX late_outcomes_pending ON late_outcomes (acknowledged_at, sequence);
|
|
633
|
+
`
|
|
634
|
+
};
|
|
635
|
+
var SCHEMA = `
|
|
636
|
+
CREATE TABLE instances (
|
|
637
|
+
instance_id TEXT PRIMARY KEY,
|
|
638
|
+
chart_name TEXT NOT NULL,
|
|
639
|
+
definition_id TEXT NOT NULL,
|
|
640
|
+
state TEXT NOT NULL,
|
|
641
|
+
status TEXT NOT NULL,
|
|
642
|
+
status_reason TEXT,
|
|
643
|
+
current_execution_id TEXT,
|
|
644
|
+
revision INTEGER NOT NULL,
|
|
645
|
+
created_at TEXT NOT NULL,
|
|
646
|
+
updated_at TEXT NOT NULL
|
|
647
|
+
);
|
|
648
|
+
CREATE TABLE executions (
|
|
649
|
+
execution_id TEXT PRIMARY KEY,
|
|
650
|
+
instance_id TEXT NOT NULL REFERENCES instances(instance_id),
|
|
651
|
+
kind TEXT NOT NULL,
|
|
652
|
+
request_id TEXT,
|
|
653
|
+
input_json TEXT NOT NULL,
|
|
654
|
+
from_state TEXT,
|
|
655
|
+
transition TEXT,
|
|
656
|
+
to_state TEXT,
|
|
657
|
+
step TEXT NOT NULL,
|
|
658
|
+
status TEXT NOT NULL,
|
|
659
|
+
handler_revision TEXT,
|
|
660
|
+
created_at TEXT NOT NULL,
|
|
661
|
+
updated_at TEXT NOT NULL,
|
|
662
|
+
UNIQUE (instance_id, request_id)
|
|
663
|
+
);
|
|
664
|
+
CREATE TABLE operations (
|
|
665
|
+
operation_id TEXT PRIMARY KEY,
|
|
666
|
+
execution_id TEXT NOT NULL REFERENCES executions(execution_id),
|
|
667
|
+
instance_id TEXT NOT NULL REFERENCES instances(instance_id),
|
|
668
|
+
callback_kind TEXT NOT NULL,
|
|
669
|
+
callback_target TEXT,
|
|
670
|
+
strategy TEXT NOT NULL,
|
|
671
|
+
status TEXT NOT NULL,
|
|
672
|
+
result_json TEXT,
|
|
673
|
+
attempts INTEGER NOT NULL,
|
|
674
|
+
attempt_limit INTEGER NOT NULL,
|
|
675
|
+
last_error TEXT,
|
|
676
|
+
last_cause TEXT,
|
|
677
|
+
next_attempt_at TEXT,
|
|
678
|
+
created_at TEXT NOT NULL,
|
|
679
|
+
updated_at TEXT NOT NULL,
|
|
680
|
+
UNIQUE (execution_id, callback_kind)
|
|
681
|
+
);
|
|
682
|
+
CREATE TABLE attempts (
|
|
683
|
+
attempt_id TEXT PRIMARY KEY,
|
|
684
|
+
operation_id TEXT NOT NULL REFERENCES operations(operation_id),
|
|
685
|
+
mode TEXT NOT NULL,
|
|
686
|
+
number INTEGER NOT NULL,
|
|
687
|
+
started_at TEXT NOT NULL,
|
|
688
|
+
finished_at TEXT,
|
|
689
|
+
outcome TEXT,
|
|
690
|
+
error TEXT
|
|
691
|
+
);
|
|
692
|
+
CREATE TABLE history (
|
|
693
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
694
|
+
instance_id TEXT NOT NULL REFERENCES instances(instance_id),
|
|
695
|
+
at TEXT NOT NULL,
|
|
696
|
+
type TEXT NOT NULL,
|
|
697
|
+
request_id TEXT,
|
|
698
|
+
operation_id TEXT,
|
|
699
|
+
attempt_id TEXT,
|
|
700
|
+
data_json TEXT NOT NULL
|
|
701
|
+
);
|
|
702
|
+
CREATE INDEX history_by_instance ON history (instance_id, sequence);
|
|
703
|
+
`;
|
|
704
|
+
var BLOCKING_STATUSES = [
|
|
705
|
+
"running",
|
|
706
|
+
"recovering",
|
|
707
|
+
"needs-attention"
|
|
708
|
+
];
|
|
709
|
+
function nowIso() {
|
|
710
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
711
|
+
}
|
|
712
|
+
var SqliteStore = class {
|
|
713
|
+
db;
|
|
714
|
+
transactionDepth = 0;
|
|
715
|
+
pendingEffects = [];
|
|
716
|
+
open = true;
|
|
717
|
+
/**
|
|
718
|
+
* `inspect` opens an existing database without creating or upgrading its schema, for
|
|
719
|
+
* read-only checks that still need ownership (the deployment check).
|
|
720
|
+
*/
|
|
721
|
+
constructor(databasePath, options = {}) {
|
|
722
|
+
this.db = new DatabaseSync(databasePath, { timeout: 0 });
|
|
723
|
+
try {
|
|
724
|
+
this.db.exec("PRAGMA locking_mode = EXCLUSIVE;");
|
|
725
|
+
this.db.exec("PRAGMA synchronous = FULL;");
|
|
726
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
727
|
+
this.db.exec("BEGIN EXCLUSIVE; COMMIT;");
|
|
728
|
+
} catch (cause) {
|
|
729
|
+
this.db.close();
|
|
730
|
+
throw new OwnershipError(
|
|
731
|
+
`Another State Flow runtime owns ${databasePath}`,
|
|
732
|
+
{ cause }
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
if (options.mode === "inspect") {
|
|
736
|
+
this.requireCurrentSchema(databasePath);
|
|
737
|
+
} else {
|
|
738
|
+
this.migrate();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
schemaVersion() {
|
|
742
|
+
const { user_version: version } = this.db.prepare("PRAGMA user_version").get();
|
|
743
|
+
return version;
|
|
744
|
+
}
|
|
745
|
+
requireCurrentSchema(databasePath) {
|
|
746
|
+
const version = this.schemaVersion();
|
|
747
|
+
if (version === 0 || version > SCHEMA_VERSION) {
|
|
748
|
+
this.close();
|
|
749
|
+
throw new SchemaError(
|
|
750
|
+
version === 0 ? `${databasePath} is not a State Flow database` : `Database schema version ${version} is newer than supported version ${SCHEMA_VERSION}`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
migrate() {
|
|
755
|
+
const version = this.schemaVersion();
|
|
756
|
+
if (version > SCHEMA_VERSION) {
|
|
757
|
+
this.db.close();
|
|
758
|
+
throw new SchemaError(
|
|
759
|
+
`Database schema version ${version} is newer than supported version ${SCHEMA_VERSION}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
if (version > 0 && version < SCHEMA_VERSION) {
|
|
763
|
+
this.transaction(() => {
|
|
764
|
+
for (let from = version; from < SCHEMA_VERSION; from += 1) {
|
|
765
|
+
const migration = MIGRATIONS[from];
|
|
766
|
+
if (migration === void 0) {
|
|
767
|
+
throw new SchemaError(`No migration from schema version ${from}`);
|
|
768
|
+
}
|
|
769
|
+
this.db.exec(migration);
|
|
770
|
+
}
|
|
771
|
+
this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
if (version === 0) {
|
|
775
|
+
this.transaction(() => {
|
|
776
|
+
this.db.exec(SCHEMA);
|
|
777
|
+
for (let from = 2; from < SCHEMA_VERSION; from += 1) {
|
|
778
|
+
this.db.exec(MIGRATIONS[from]);
|
|
779
|
+
}
|
|
780
|
+
this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
get isOpen() {
|
|
785
|
+
return this.open;
|
|
786
|
+
}
|
|
787
|
+
close() {
|
|
788
|
+
if (this.open) {
|
|
789
|
+
this.open = false;
|
|
790
|
+
this.db.close();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/** Runs `work` atomically. Nested calls join the outer transaction. */
|
|
794
|
+
transaction(work) {
|
|
795
|
+
if (this.transactionDepth > 0) {
|
|
796
|
+
return work();
|
|
797
|
+
}
|
|
798
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
799
|
+
this.transactionDepth += 1;
|
|
800
|
+
let committed = false;
|
|
801
|
+
try {
|
|
802
|
+
const result = work();
|
|
803
|
+
this.db.exec("COMMIT");
|
|
804
|
+
committed = true;
|
|
805
|
+
return result;
|
|
806
|
+
} catch (error) {
|
|
807
|
+
this.db.exec("ROLLBACK");
|
|
808
|
+
throw error;
|
|
809
|
+
} finally {
|
|
810
|
+
this.transactionDepth -= 1;
|
|
811
|
+
const effects = this.pendingEffects.splice(0);
|
|
812
|
+
if (committed) {
|
|
813
|
+
for (const effect of effects) {
|
|
814
|
+
effect();
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Runs `effect` once the current transaction commits (immediately outside one), and drops it
|
|
821
|
+
* on rollback. Used for notifications and timers, which must reflect committed state only.
|
|
822
|
+
*/
|
|
823
|
+
afterCommit(effect) {
|
|
824
|
+
if (this.transactionDepth > 0) {
|
|
825
|
+
this.pendingEffects.push(effect);
|
|
826
|
+
} else {
|
|
827
|
+
effect();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// --- instances -----------------------------------------------------------
|
|
831
|
+
getInstance(instanceId2) {
|
|
832
|
+
return this.db.prepare("SELECT * FROM instances WHERE instance_id = ?").get(instanceId2);
|
|
833
|
+
}
|
|
834
|
+
insertInstance(row) {
|
|
835
|
+
const at = nowIso();
|
|
836
|
+
this.db.prepare(
|
|
837
|
+
`INSERT INTO instances (instance_id, chart_name, definition_id, state, status, status_reason,
|
|
838
|
+
current_execution_id, revision, created_at, updated_at)
|
|
839
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
|
840
|
+
).run(
|
|
841
|
+
row.instance_id,
|
|
842
|
+
row.chart_name,
|
|
843
|
+
row.definition_id,
|
|
844
|
+
row.state,
|
|
845
|
+
row.status,
|
|
846
|
+
row.status_reason,
|
|
847
|
+
row.current_execution_id,
|
|
848
|
+
at,
|
|
849
|
+
at
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
/** Inserts a nested scope row (ADR 0096). */
|
|
853
|
+
insertScope(row) {
|
|
854
|
+
const at = nowIso();
|
|
855
|
+
this.db.prepare(
|
|
856
|
+
`INSERT INTO instances (instance_id, chart_name, definition_id, state, status, status_reason,
|
|
857
|
+
current_execution_id, revision, created_at, updated_at, root_instance_id, parent_instance_id,
|
|
858
|
+
scope_path, segment, graph, activation_id, member_key, input_json, position, active)
|
|
859
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
|
860
|
+
).run(
|
|
861
|
+
row.instance_id,
|
|
862
|
+
row.chart_name,
|
|
863
|
+
row.definition_id,
|
|
864
|
+
row.state,
|
|
865
|
+
row.status,
|
|
866
|
+
row.status_reason,
|
|
867
|
+
row.current_execution_id,
|
|
868
|
+
at,
|
|
869
|
+
at,
|
|
870
|
+
row.root_instance_id,
|
|
871
|
+
row.parent_instance_id,
|
|
872
|
+
row.scope_path,
|
|
873
|
+
row.segment,
|
|
874
|
+
row.graph,
|
|
875
|
+
row.activation_id,
|
|
876
|
+
row.member_key,
|
|
877
|
+
row.input_json,
|
|
878
|
+
row.position
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
getScope(instanceId2) {
|
|
882
|
+
return this.getInstance(instanceId2);
|
|
883
|
+
}
|
|
884
|
+
/** Every scope of a root, parents before children (by path depth, then authored position). */
|
|
885
|
+
listScopes(rootInstanceId) {
|
|
886
|
+
return this.db.prepare(
|
|
887
|
+
`SELECT * FROM instances WHERE root_instance_id = ?
|
|
888
|
+
ORDER BY length(scope_path) - length(replace(scope_path, '/', '')) ASC, created_at ASC, position ASC`
|
|
889
|
+
).all(rootInstanceId);
|
|
890
|
+
}
|
|
891
|
+
/** Direct child scopes of one scope (or root). */
|
|
892
|
+
listChildScopes(parentInstanceId) {
|
|
893
|
+
return this.db.prepare(
|
|
894
|
+
"SELECT * FROM instances WHERE parent_instance_id = ? ORDER BY position ASC"
|
|
895
|
+
).all(parentInstanceId);
|
|
896
|
+
}
|
|
897
|
+
updateScope(instanceId2, changes) {
|
|
898
|
+
this.update("instances", "instance_id", instanceId2, changes, {
|
|
899
|
+
bumpRevision: true
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
/** Latest execution with this request ID in any scope of a root at this path and member key. */
|
|
903
|
+
findScopedExecutionByRequest(rootInstanceId, scopePath, memberKey, requestId2) {
|
|
904
|
+
return this.db.prepare(
|
|
905
|
+
`SELECT executions.* FROM executions JOIN instances ON instances.instance_id = executions.instance_id
|
|
906
|
+
WHERE instances.root_instance_id = ? AND instances.scope_path = ?
|
|
907
|
+
AND instances.member_key IS ? AND executions.request_id = ?
|
|
908
|
+
ORDER BY executions.created_at DESC LIMIT 1`
|
|
909
|
+
).get(rootInstanceId, scopePath, memberKey, requestId2);
|
|
910
|
+
}
|
|
911
|
+
/** Handler revisions of unfinished executions across a root and its scopes. */
|
|
912
|
+
unfinishedHandlerRevisions(rootInstanceId) {
|
|
913
|
+
const rows = this.db.prepare(
|
|
914
|
+
`SELECT DISTINCT executions.handler_revision AS revision FROM executions
|
|
915
|
+
JOIN instances ON instances.instance_id = executions.instance_id
|
|
916
|
+
WHERE (instances.instance_id = ? OR instances.root_instance_id = ?)
|
|
917
|
+
AND executions.execution_id = instances.current_execution_id
|
|
918
|
+
AND instances.status IN ('running', 'recovering', 'needs-attention')`
|
|
919
|
+
).all(rootInstanceId, rootInstanceId);
|
|
920
|
+
return rows.map((row) => row.revision);
|
|
921
|
+
}
|
|
922
|
+
// --- late outcomes (ADR 0096) ---------------------------------------------
|
|
923
|
+
insertLateOutcome(row) {
|
|
924
|
+
this.db.prepare(
|
|
925
|
+
`INSERT INTO late_outcomes (root_instance_id, scope_instance_id, scope_path, activation_id, member_key,
|
|
926
|
+
operation_id, callback_kind, callback_target, source, result_json, recorded_at)
|
|
927
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
928
|
+
).run(
|
|
929
|
+
row.root_instance_id,
|
|
930
|
+
row.scope_instance_id,
|
|
931
|
+
row.scope_path,
|
|
932
|
+
row.activation_id,
|
|
933
|
+
row.member_key,
|
|
934
|
+
row.operation_id,
|
|
935
|
+
row.callback_kind,
|
|
936
|
+
row.callback_target,
|
|
937
|
+
row.source,
|
|
938
|
+
row.result_json,
|
|
939
|
+
nowIso()
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
listPendingLateOutcomes(rootInstanceId) {
|
|
943
|
+
return rootInstanceId === null ? this.db.prepare(
|
|
944
|
+
"SELECT * FROM late_outcomes WHERE acknowledged_at IS NULL ORDER BY sequence ASC"
|
|
945
|
+
).all() : this.db.prepare(
|
|
946
|
+
"SELECT * FROM late_outcomes WHERE acknowledged_at IS NULL AND root_instance_id = ? ORDER BY sequence ASC"
|
|
947
|
+
).all(rootInstanceId);
|
|
948
|
+
}
|
|
949
|
+
getLateOutcome(sequence) {
|
|
950
|
+
return this.db.prepare("SELECT * FROM late_outcomes WHERE sequence = ?").get(sequence);
|
|
951
|
+
}
|
|
952
|
+
acknowledgeLateOutcome(sequence) {
|
|
953
|
+
const result = this.db.prepare(
|
|
954
|
+
"UPDATE late_outcomes SET acknowledged_at = ? WHERE sequence = ? AND acknowledged_at IS NULL"
|
|
955
|
+
).run(nowIso(), sequence);
|
|
956
|
+
return Number(result.changes) > 0;
|
|
957
|
+
}
|
|
958
|
+
updateInstance(instanceId2, changes) {
|
|
959
|
+
this.update("instances", "instance_id", instanceId2, changes, {
|
|
960
|
+
bumpRevision: true
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
// --- executions ----------------------------------------------------------
|
|
964
|
+
getExecution(executionId) {
|
|
965
|
+
return this.db.prepare("SELECT * FROM executions WHERE execution_id = ?").get(executionId);
|
|
966
|
+
}
|
|
967
|
+
findExecutionByRequest(instanceId2, requestId2) {
|
|
968
|
+
return this.db.prepare(
|
|
969
|
+
"SELECT * FROM executions WHERE instance_id = ? AND request_id = ?"
|
|
970
|
+
).get(instanceId2, requestId2);
|
|
971
|
+
}
|
|
972
|
+
insertExecution(row) {
|
|
973
|
+
const at = nowIso();
|
|
974
|
+
this.db.prepare(
|
|
975
|
+
`INSERT INTO executions (execution_id, instance_id, kind, request_id, input_json, from_state,
|
|
976
|
+
transition, to_state, step, status, handler_revision, created_at, updated_at)
|
|
977
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
978
|
+
).run(
|
|
979
|
+
row.execution_id,
|
|
980
|
+
row.instance_id,
|
|
981
|
+
row.kind,
|
|
982
|
+
row.request_id,
|
|
983
|
+
row.input_json,
|
|
984
|
+
row.from_state,
|
|
985
|
+
row.transition,
|
|
986
|
+
row.to_state,
|
|
987
|
+
row.step,
|
|
988
|
+
row.status,
|
|
989
|
+
row.handler_revision,
|
|
990
|
+
at,
|
|
991
|
+
at
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
updateExecution(executionId, changes) {
|
|
995
|
+
this.update("executions", "execution_id", executionId, changes);
|
|
996
|
+
}
|
|
997
|
+
// --- operations ----------------------------------------------------------
|
|
998
|
+
getOperation(operationId) {
|
|
999
|
+
return this.db.prepare("SELECT * FROM operations WHERE operation_id = ?").get(operationId);
|
|
1000
|
+
}
|
|
1001
|
+
findOperation(executionId, kind) {
|
|
1002
|
+
return this.db.prepare(
|
|
1003
|
+
"SELECT * FROM operations WHERE execution_id = ? AND callback_kind = ?"
|
|
1004
|
+
).get(executionId, kind);
|
|
1005
|
+
}
|
|
1006
|
+
/** The execution's operation that is not completed, if any. */
|
|
1007
|
+
findUnresolvedOperation(executionId) {
|
|
1008
|
+
return this.db.prepare(
|
|
1009
|
+
"SELECT * FROM operations WHERE execution_id = ? AND status != 'completed' ORDER BY created_at DESC LIMIT 1"
|
|
1010
|
+
).get(executionId);
|
|
1011
|
+
}
|
|
1012
|
+
insertOperation(row) {
|
|
1013
|
+
const at = nowIso();
|
|
1014
|
+
this.db.prepare(
|
|
1015
|
+
`INSERT INTO operations (operation_id, execution_id, instance_id, callback_kind, callback_target,
|
|
1016
|
+
strategy, status, attempts, attempt_limit, created_at, updated_at)
|
|
1017
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1018
|
+
).run(
|
|
1019
|
+
row.operation_id,
|
|
1020
|
+
row.execution_id,
|
|
1021
|
+
row.instance_id,
|
|
1022
|
+
row.callback_kind,
|
|
1023
|
+
row.callback_target,
|
|
1024
|
+
row.strategy,
|
|
1025
|
+
row.status,
|
|
1026
|
+
row.attempts,
|
|
1027
|
+
row.attempt_limit,
|
|
1028
|
+
at,
|
|
1029
|
+
at
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
updateOperation(operationId, changes) {
|
|
1033
|
+
this.update("operations", "operation_id", operationId, changes);
|
|
1034
|
+
}
|
|
1035
|
+
// --- attempts ------------------------------------------------------------
|
|
1036
|
+
insertAttempt(row) {
|
|
1037
|
+
this.db.prepare(
|
|
1038
|
+
"INSERT INTO attempts (attempt_id, operation_id, mode, number, started_at) VALUES (?, ?, ?, ?, ?)"
|
|
1039
|
+
).run(row.attempt_id, row.operation_id, row.mode, row.number, nowIso());
|
|
1040
|
+
}
|
|
1041
|
+
finishAttempt(attemptId, outcome, error) {
|
|
1042
|
+
this.db.prepare(
|
|
1043
|
+
"UPDATE attempts SET finished_at = ?, outcome = ?, error = ? WHERE attempt_id = ?"
|
|
1044
|
+
).run(nowIso(), outcome, error, attemptId);
|
|
1045
|
+
}
|
|
1046
|
+
/** Attempts left open by a process that stopped mid-callback. */
|
|
1047
|
+
finishInterruptedAttempts(operationId) {
|
|
1048
|
+
this.db.prepare(
|
|
1049
|
+
"UPDATE attempts SET finished_at = ?, outcome = 'interrupted' WHERE operation_id = ? AND finished_at IS NULL"
|
|
1050
|
+
).run(nowIso(), operationId);
|
|
1051
|
+
}
|
|
1052
|
+
// --- history -------------------------------------------------------------
|
|
1053
|
+
appendHistory(row) {
|
|
1054
|
+
this.db.prepare(
|
|
1055
|
+
`INSERT INTO history (instance_id, at, type, request_id, operation_id, attempt_id, data_json)
|
|
1056
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
1057
|
+
).run(
|
|
1058
|
+
row.instance_id,
|
|
1059
|
+
nowIso(),
|
|
1060
|
+
row.type,
|
|
1061
|
+
row.request_id,
|
|
1062
|
+
row.operation_id,
|
|
1063
|
+
row.attempt_id,
|
|
1064
|
+
row.data_json
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
/** One page of history in sequence order, after `afterSequence` (exclusive). */
|
|
1068
|
+
listHistory(instanceId2, page) {
|
|
1069
|
+
return this.db.prepare(
|
|
1070
|
+
"SELECT * FROM history WHERE instance_id = ? AND sequence > ? ORDER BY sequence ASC LIMIT ?"
|
|
1071
|
+
).all(instanceId2, page.afterSequence, page.limit);
|
|
1072
|
+
}
|
|
1073
|
+
/** One page of instances ordered by ID, after `afterInstanceId` (exclusive). */
|
|
1074
|
+
listInstances(query) {
|
|
1075
|
+
const where = ["root_instance_id IS NULL"];
|
|
1076
|
+
const values = [];
|
|
1077
|
+
if (query.flowName !== null) {
|
|
1078
|
+
where.push("chart_name = ?");
|
|
1079
|
+
values.push(query.flowName);
|
|
1080
|
+
}
|
|
1081
|
+
if (query.statuses !== null) {
|
|
1082
|
+
if (query.statuses.length === 0) {
|
|
1083
|
+
return [];
|
|
1084
|
+
}
|
|
1085
|
+
where.push(`status IN (${query.statuses.map(() => "?").join(", ")})`);
|
|
1086
|
+
values.push(...query.statuses);
|
|
1087
|
+
}
|
|
1088
|
+
if (query.afterInstanceId !== null) {
|
|
1089
|
+
where.push("instance_id > ?");
|
|
1090
|
+
values.push(query.afterInstanceId);
|
|
1091
|
+
}
|
|
1092
|
+
const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
1093
|
+
return this.db.prepare(
|
|
1094
|
+
`SELECT * FROM instances ${clause} ORDER BY instance_id ASC LIMIT ?`
|
|
1095
|
+
).all(...values, query.limit);
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Every instance with unfinished execution (running, recovering or needs attention), ordered
|
|
1099
|
+
* by ID. Unpaged: a deployment decision must see all of them.
|
|
1100
|
+
*/
|
|
1101
|
+
listUnfinishedInstances() {
|
|
1102
|
+
return this.db.prepare(
|
|
1103
|
+
`SELECT * FROM instances WHERE status IN (${BLOCKING_STATUSES.map(() => "?").join(", ")}) ORDER BY instance_id ASC`
|
|
1104
|
+
).all(...BLOCKING_STATUSES);
|
|
1105
|
+
}
|
|
1106
|
+
// --- helpers -------------------------------------------------------------
|
|
1107
|
+
update(table, keyColumn, key, changes, options = {}) {
|
|
1108
|
+
const columns = Object.keys(changes).filter(
|
|
1109
|
+
(column) => changes[column] !== void 0
|
|
1110
|
+
);
|
|
1111
|
+
const assignments = columns.map((column) => `${column} = ?`);
|
|
1112
|
+
assignments.push("updated_at = ?");
|
|
1113
|
+
if (options.bumpRevision) {
|
|
1114
|
+
assignments.push("revision = revision + 1");
|
|
1115
|
+
}
|
|
1116
|
+
const values = columns.map((column) => changes[column] ?? null);
|
|
1117
|
+
this.db.prepare(
|
|
1118
|
+
`UPDATE ${table} SET ${assignments.join(", ")} WHERE ${keyColumn} = ?`
|
|
1119
|
+
).run(...values, nowIso(), key);
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
// src/durable/deployment.ts
|
|
1124
|
+
function deploymentBlockers(store) {
|
|
1125
|
+
return Object.freeze(
|
|
1126
|
+
store.listUnfinishedInstances().map((row) => toBlocker(store, row)).sort(
|
|
1127
|
+
(a, b) => a.instanceId < b.instanceId ? -1 : a.instanceId > b.instanceId ? 1 : 0
|
|
1128
|
+
)
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
function toBlocker(store, row) {
|
|
1132
|
+
const root = row.root_instance_id === null || row.root_instance_id === void 0 ? void 0 : store.getInstance(row.root_instance_id);
|
|
1133
|
+
return Object.freeze({
|
|
1134
|
+
instanceId: root?.instance_id ?? row.instance_id,
|
|
1135
|
+
flowName: root?.chart_name ?? row.chart_name,
|
|
1136
|
+
state: row.state,
|
|
1137
|
+
status: row.status,
|
|
1138
|
+
statusReason: row.status_reason,
|
|
1139
|
+
scope: root === void 0 ? null : Object.freeze({
|
|
1140
|
+
path: row.scope_path ?? "",
|
|
1141
|
+
activationId: row.activation_id ?? "",
|
|
1142
|
+
memberKey: row.member_key ?? null,
|
|
1143
|
+
active: row.active === 1
|
|
1144
|
+
})
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
function checkDeployment(options) {
|
|
1148
|
+
const { databasePath } = options;
|
|
1149
|
+
if (!existsSync(databasePath)) {
|
|
1150
|
+
throw new InputError(`No State Flow database exists at ${databasePath}`);
|
|
1151
|
+
}
|
|
1152
|
+
let store;
|
|
1153
|
+
try {
|
|
1154
|
+
store = new SqliteStore(databasePath, { mode: "inspect" });
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
if (error instanceof OwnershipError) {
|
|
1157
|
+
return Object.freeze({ outcome: "owned", message: error.message });
|
|
1158
|
+
}
|
|
1159
|
+
throw error;
|
|
1160
|
+
}
|
|
1161
|
+
try {
|
|
1162
|
+
const blockers = deploymentBlockers(store);
|
|
1163
|
+
return blockers.length > 0 ? Object.freeze({ outcome: "blocked", blockers }) : Object.freeze({ outcome: "ready" });
|
|
1164
|
+
} finally {
|
|
1165
|
+
store.close();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/durable/ids.ts
|
|
1170
|
+
import { randomUUID } from "node:crypto";
|
|
1171
|
+
var MAX_ID_LENGTH = 256;
|
|
1172
|
+
var CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
1173
|
+
function validateApplicationId(kind, value) {
|
|
1174
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1175
|
+
throw new InputError(`${kind} must be a non-empty string`);
|
|
1176
|
+
}
|
|
1177
|
+
if (value.length > MAX_ID_LENGTH) {
|
|
1178
|
+
throw new InputError(`${kind} must be at most ${MAX_ID_LENGTH} characters`);
|
|
1179
|
+
}
|
|
1180
|
+
if (CONTROL_CHARACTERS.test(value)) {
|
|
1181
|
+
throw new InputError(`${kind} must not contain control characters`);
|
|
1182
|
+
}
|
|
1183
|
+
return value;
|
|
1184
|
+
}
|
|
1185
|
+
function instanceId(value) {
|
|
1186
|
+
return validateApplicationId("instanceId", value);
|
|
1187
|
+
}
|
|
1188
|
+
function requestId(value) {
|
|
1189
|
+
return validateApplicationId("requestId", value);
|
|
1190
|
+
}
|
|
1191
|
+
function mintOperationId() {
|
|
1192
|
+
return `op_${randomUUID()}`;
|
|
1193
|
+
}
|
|
1194
|
+
function mintAttemptId() {
|
|
1195
|
+
return `att_${randomUUID()}`;
|
|
1196
|
+
}
|
|
1197
|
+
function mintExecutionId() {
|
|
1198
|
+
return `exe_${randomUUID()}`;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// src/durable/engine.ts
|
|
1202
|
+
var MAX_ATTEMPTS = 4;
|
|
1203
|
+
function clampLimit(value, fallback, max) {
|
|
1204
|
+
if (value === void 0 || !Number.isFinite(value)) {
|
|
1205
|
+
return fallback;
|
|
1206
|
+
}
|
|
1207
|
+
return Math.min(max, Math.max(1, Math.floor(value)));
|
|
1208
|
+
}
|
|
1209
|
+
var RECOVERY_DELAYS_MS = [1e3, 5e3, 3e4];
|
|
1210
|
+
var BUSY_RETRY_DELAY_MS = 100;
|
|
1211
|
+
var InstanceCore = class {
|
|
1212
|
+
constructor(id, flow, scope = null) {
|
|
1213
|
+
this.id = id;
|
|
1214
|
+
this.flow = flow;
|
|
1215
|
+
this.scope = scope;
|
|
1216
|
+
}
|
|
1217
|
+
id;
|
|
1218
|
+
flow;
|
|
1219
|
+
scope;
|
|
1220
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1221
|
+
/** Aborted when a parent Transition interrupts this scope; never for a root (ADR 0095). */
|
|
1222
|
+
abort = new AbortController();
|
|
1223
|
+
activeExecutionId = null;
|
|
1224
|
+
timer = null;
|
|
1225
|
+
infrastructureRetries = 0;
|
|
1226
|
+
/**
|
|
1227
|
+
* Set when an infrastructure error stopped execution in this process; shown in snapshots.
|
|
1228
|
+
* `restartRecovery` or opening the instance again resumes from persisted state.
|
|
1229
|
+
*/
|
|
1230
|
+
stall = null;
|
|
1231
|
+
get stalled() {
|
|
1232
|
+
return this.stall !== null;
|
|
1233
|
+
}
|
|
1234
|
+
/** The root instance this core belongs to (itself for a root). */
|
|
1235
|
+
get rootId() {
|
|
1236
|
+
return this.scope?.rootId ?? this.id;
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
var VOID_CALLBACKS = /* @__PURE__ */ new Set([
|
|
1240
|
+
"on-enter",
|
|
1241
|
+
"on-exit"
|
|
1242
|
+
]);
|
|
1243
|
+
var PARENT_EXIT_TRANSITION_NAME = "parent-exit";
|
|
1244
|
+
function errorMessage(error) {
|
|
1245
|
+
if (error instanceof Error) {
|
|
1246
|
+
return `${error.name}: ${error.message}`;
|
|
1247
|
+
}
|
|
1248
|
+
return String(error);
|
|
1249
|
+
}
|
|
1250
|
+
function delayAfterAttempt(attempts) {
|
|
1251
|
+
return RECOVERY_DELAYS_MS[attempts - 1] ?? RECOVERY_DELAYS_MS[RECOVERY_DELAYS_MS.length - 1];
|
|
1252
|
+
}
|
|
1253
|
+
function validateCallbackResult(definition, kind, value, transitionName, stateName = null) {
|
|
1254
|
+
if (VOID_CALLBACKS.has(kind)) {
|
|
1255
|
+
return { ok: true, result: null };
|
|
1256
|
+
}
|
|
1257
|
+
if (kind === "children") {
|
|
1258
|
+
return validateChildInputs(
|
|
1259
|
+
definition.statesByName.get(stateName ?? ""),
|
|
1260
|
+
value
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
if (kind === "on-transition") {
|
|
1264
|
+
const transition = definition.transitionsByName.get(transitionName ?? "");
|
|
1265
|
+
if (!transition || !isBranching(transition)) {
|
|
1266
|
+
return { ok: true, result: null };
|
|
1267
|
+
}
|
|
1268
|
+
const names = transition.destinations.map((state) => state.name);
|
|
1269
|
+
const parsed2 = z2.enum(names).safeParse(value);
|
|
1270
|
+
return parsed2.success ? { ok: true, result: parsed2.data } : {
|
|
1271
|
+
ok: false,
|
|
1272
|
+
message: `onTransition for "${transition.name}" must return one of its destinations (${names.map((name) => JSON.stringify(name)).join(", ")}); received ${value === void 0 ? "nothing" : JSON.stringify(value)}`
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
const schema = kind === "guard" ? z2.boolean() : z2.enum([...definition.statesByName.keys()]);
|
|
1276
|
+
const parsed = schema.safeParse(value);
|
|
1277
|
+
return parsed.success ? { ok: true, result: parsed.data } : {
|
|
1278
|
+
ok: false,
|
|
1279
|
+
message: `${kind === "guard" ? "guard result must be a boolean" : "start state must be an authored state"}: ${parsed.error.message}`
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
function validateChildInputs(state, value) {
|
|
1283
|
+
const composition = state?.composition;
|
|
1284
|
+
if (!state || !composition) {
|
|
1285
|
+
return { ok: false, message: "children runs only for a composite State" };
|
|
1286
|
+
}
|
|
1287
|
+
const problem = findNonJsonValue(value ?? null);
|
|
1288
|
+
if (problem) {
|
|
1289
|
+
return { ok: false, message: `children result is not JSON: ${problem}` };
|
|
1290
|
+
}
|
|
1291
|
+
const supplied = value ?? {};
|
|
1292
|
+
if (typeof supplied !== "object" || Array.isArray(supplied)) {
|
|
1293
|
+
return { ok: false, message: "children must return an object" };
|
|
1294
|
+
}
|
|
1295
|
+
const result = {};
|
|
1296
|
+
const expected = /* @__PURE__ */ new Set();
|
|
1297
|
+
const checkInput = (graph, input, label) => {
|
|
1298
|
+
const schema = graph.inputSchema;
|
|
1299
|
+
if (schema === null) {
|
|
1300
|
+
return input === void 0 || input === null ? null : `${label} takes no input`;
|
|
1301
|
+
}
|
|
1302
|
+
const parsed = schema.safeParse(input);
|
|
1303
|
+
if (!parsed.success) {
|
|
1304
|
+
return `${label} input is invalid: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`;
|
|
1305
|
+
}
|
|
1306
|
+
if (canonicalJson(parsed.data) !== canonicalJson(input ?? null)) {
|
|
1307
|
+
return `${label} input schema must preserve the JSON input`;
|
|
1308
|
+
}
|
|
1309
|
+
return null;
|
|
1310
|
+
};
|
|
1311
|
+
for (const region of composition.regions) {
|
|
1312
|
+
if (region.kind === "inline") continue;
|
|
1313
|
+
expected.add(region.name);
|
|
1314
|
+
const supplied_ = supplied[region.name];
|
|
1315
|
+
if (region.kind === "flow") {
|
|
1316
|
+
const issue = checkInput(region.graph, supplied_, `"${region.name}"`);
|
|
1317
|
+
if (issue) return { ok: false, message: issue };
|
|
1318
|
+
if (region.graph.inputSchema !== null) {
|
|
1319
|
+
result[region.name] = supplied_;
|
|
1320
|
+
}
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
if (!Array.isArray(supplied_)) {
|
|
1324
|
+
return {
|
|
1325
|
+
ok: false,
|
|
1326
|
+
message: `FlowList "${region.name}" needs an array of { key, input } members`
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1330
|
+
const members = [];
|
|
1331
|
+
for (const [index, member] of supplied_.entries()) {
|
|
1332
|
+
const record = member;
|
|
1333
|
+
if (typeof record !== "object" || record === null || typeof record.key !== "string" || record.key.length === 0 || Object.keys(record).some((key) => key !== "key" && key !== "input")) {
|
|
1334
|
+
return {
|
|
1335
|
+
ok: false,
|
|
1336
|
+
message: `FlowList "${region.name}" member ${index} must be { key: non-empty string, input }`
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
if (keys.has(record.key)) {
|
|
1340
|
+
return {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
message: `FlowList "${region.name}" has duplicate key ${JSON.stringify(record.key)}`
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
keys.add(record.key);
|
|
1346
|
+
const issue = checkInput(
|
|
1347
|
+
region.graph,
|
|
1348
|
+
record.input,
|
|
1349
|
+
`FlowList "${region.name}" member ${JSON.stringify(record.key)}`
|
|
1350
|
+
);
|
|
1351
|
+
if (issue) return { ok: false, message: issue };
|
|
1352
|
+
members.push({
|
|
1353
|
+
key: record.key,
|
|
1354
|
+
input: record.input ?? null
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
result[region.name] = members;
|
|
1358
|
+
}
|
|
1359
|
+
const unexpected = Object.keys(supplied).filter((key) => !expected.has(key));
|
|
1360
|
+
if (unexpected.length > 0) {
|
|
1361
|
+
return {
|
|
1362
|
+
ok: false,
|
|
1363
|
+
message: `children returned unknown regions: ${unexpected.join(", ")}`
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
return { ok: true, result };
|
|
1367
|
+
}
|
|
1368
|
+
var RuntimeCore = class {
|
|
1369
|
+
constructor(store, onObserverError, onRecoveryError) {
|
|
1370
|
+
this.store = store;
|
|
1371
|
+
this.onObserverError = onObserverError;
|
|
1372
|
+
this.onRecoveryError = onRecoveryError;
|
|
1373
|
+
}
|
|
1374
|
+
store;
|
|
1375
|
+
onObserverError;
|
|
1376
|
+
onRecoveryError;
|
|
1377
|
+
instances = /* @__PURE__ */ new Map();
|
|
1378
|
+
/** Late outcome listeners (ADR 0096); delivery happens after commit. */
|
|
1379
|
+
lateListeners = /* @__PURE__ */ new Set();
|
|
1380
|
+
/** Dependencies of a registered flow by name, for placed flows (ADR 0096). */
|
|
1381
|
+
dependenciesOf = () => void 0;
|
|
1382
|
+
graphCores = /* @__PURE__ */ new WeakMap();
|
|
1383
|
+
active = /* @__PURE__ */ new Set();
|
|
1384
|
+
abort = new AbortController();
|
|
1385
|
+
closing = false;
|
|
1386
|
+
closePromise = null;
|
|
1387
|
+
assertOpen() {
|
|
1388
|
+
if (this.closing) {
|
|
1389
|
+
throw new RuntimeClosedError("The durable runtime is closed");
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Stops admission and scheduling, aborts the shared signal, waits for active callbacks to
|
|
1394
|
+
* settle and checkpoint, then releases the database (ADR 0061). With `timeoutMs`, rejects with
|
|
1395
|
+
* {@link ShutdownTimeoutError} if callbacks are still running by then; the database
|
|
1396
|
+
* stays owned because those callbacks may still write (ADR 0075). Calling close again keeps waiting.
|
|
1397
|
+
*/
|
|
1398
|
+
close(options = {}) {
|
|
1399
|
+
if (!this.closePromise) {
|
|
1400
|
+
this.closing = true;
|
|
1401
|
+
for (const instance of this.instances.values()) {
|
|
1402
|
+
this.clearTimer(instance);
|
|
1403
|
+
}
|
|
1404
|
+
this.abort.abort(
|
|
1405
|
+
new RuntimeClosedError("The durable runtime is closing")
|
|
1406
|
+
);
|
|
1407
|
+
this.closePromise = (async () => {
|
|
1408
|
+
while (this.active.size > 0) {
|
|
1409
|
+
await Promise.allSettled([...this.active]);
|
|
1410
|
+
}
|
|
1411
|
+
this.store.close();
|
|
1412
|
+
})();
|
|
1413
|
+
}
|
|
1414
|
+
const { timeoutMs } = options;
|
|
1415
|
+
if (timeoutMs === void 0) {
|
|
1416
|
+
return this.closePromise;
|
|
1417
|
+
}
|
|
1418
|
+
let timer;
|
|
1419
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
1420
|
+
timer = setTimeout(
|
|
1421
|
+
() => reject(
|
|
1422
|
+
new ShutdownTimeoutError(
|
|
1423
|
+
`${this.active.size} callback execution(s) did not settle within ${timeoutMs} ms; the database is still owned. Exit the process to release it; interrupted work recovers on the next open.`
|
|
1424
|
+
)
|
|
1425
|
+
),
|
|
1426
|
+
timeoutMs
|
|
1427
|
+
);
|
|
1428
|
+
});
|
|
1429
|
+
return Promise.race([this.closePromise, timeout]).finally(
|
|
1430
|
+
() => clearTimeout(timer)
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* The runtime-backed deployment gate (ADR 0091, 0094). Reads unfinished execution from storage
|
|
1435
|
+
* and, when there is none, starts closing in the same synchronous step, so no admission can
|
|
1436
|
+
* fall between the check and the handover. A blocked or failed check changes nothing.
|
|
1437
|
+
*/
|
|
1438
|
+
async handover(options = {}) {
|
|
1439
|
+
this.assertOpen();
|
|
1440
|
+
this.assertStoreOpen();
|
|
1441
|
+
const blockers = deploymentBlockers(this.store);
|
|
1442
|
+
if (blockers.length > 0) {
|
|
1443
|
+
return Object.freeze({ outcome: "blocked", blockers });
|
|
1444
|
+
}
|
|
1445
|
+
await this.close(options);
|
|
1446
|
+
return Object.freeze({ outcome: "handed-over" });
|
|
1447
|
+
}
|
|
1448
|
+
get isClosed() {
|
|
1449
|
+
return this.closing;
|
|
1450
|
+
}
|
|
1451
|
+
/** Simulates a crash: stops scheduling and drops the connection without waiting. */
|
|
1452
|
+
abandon() {
|
|
1453
|
+
this.closing = true;
|
|
1454
|
+
for (const instance of this.instances.values()) {
|
|
1455
|
+
this.clearTimer(instance);
|
|
1456
|
+
}
|
|
1457
|
+
this.closePromise = Promise.resolve();
|
|
1458
|
+
this.store.close();
|
|
1459
|
+
}
|
|
1460
|
+
// --- observation ---------------------------------------------------------
|
|
1461
|
+
snapshot(instance) {
|
|
1462
|
+
return this.snapshotOf(instance.id);
|
|
1463
|
+
}
|
|
1464
|
+
/** Snapshot read from storage; works for instances this runtime has not opened. */
|
|
1465
|
+
snapshotOf(instanceId2) {
|
|
1466
|
+
this.assertStoreOpen();
|
|
1467
|
+
const row = this.requireInstanceRow(instanceId2);
|
|
1468
|
+
return Object.freeze({
|
|
1469
|
+
instanceId: instanceId2,
|
|
1470
|
+
flowName: row.chart_name,
|
|
1471
|
+
state: row.state,
|
|
1472
|
+
revision: row.revision,
|
|
1473
|
+
status: Object.freeze(this.status(row)),
|
|
1474
|
+
stall: this.instances.get(instanceId2)?.stall ?? null,
|
|
1475
|
+
scopes: Object.freeze(
|
|
1476
|
+
row.root_instance_id ? [] : this.scopeSnapshots(instanceId2)
|
|
1477
|
+
)
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
/** Active scopes and exited scopes that retain unresolved work, parents first (ADR 0096). */
|
|
1481
|
+
scopeSnapshots(rootId) {
|
|
1482
|
+
const rows = this.store.listScopes(rootId);
|
|
1483
|
+
const byId = new Map(rows.map((row) => [row.instance_id, row]));
|
|
1484
|
+
return rows.filter(
|
|
1485
|
+
(row) => row.active === 1 || row.status === "running" || row.status === "recovering" || row.status === "needs-attention"
|
|
1486
|
+
).map(
|
|
1487
|
+
(row) => Object.freeze({
|
|
1488
|
+
path: row.scope_path ?? "",
|
|
1489
|
+
segment: row.segment ?? "",
|
|
1490
|
+
activationId: row.activation_id ?? "",
|
|
1491
|
+
parentActivationId: row.parent_instance_id === rootId ? null : byId.get(row.parent_instance_id ?? "")?.activation_id ?? null,
|
|
1492
|
+
memberKey: row.member_key,
|
|
1493
|
+
flowName: row.chart_name,
|
|
1494
|
+
state: row.state,
|
|
1495
|
+
active: row.active === 1,
|
|
1496
|
+
status: Object.freeze(this.status(row))
|
|
1497
|
+
})
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
status(row) {
|
|
1501
|
+
const execution = row.current_execution_id === null ? void 0 : this.store.getExecution(row.current_execution_id);
|
|
1502
|
+
switch (row.status) {
|
|
1503
|
+
case "exited":
|
|
1504
|
+
return {
|
|
1505
|
+
kind: "exited",
|
|
1506
|
+
interrupted: row.exit_reason === "interrupted"
|
|
1507
|
+
};
|
|
1508
|
+
case "ready":
|
|
1509
|
+
return { kind: "ready" };
|
|
1510
|
+
case "terminated":
|
|
1511
|
+
return { kind: "terminated", reason: row.status_reason ?? "" };
|
|
1512
|
+
case "completed":
|
|
1513
|
+
return { kind: "completed" };
|
|
1514
|
+
case "running":
|
|
1515
|
+
return {
|
|
1516
|
+
kind: "running",
|
|
1517
|
+
requestId: execution?.request_id ?? null,
|
|
1518
|
+
transition: execution?.transition ?? null,
|
|
1519
|
+
destination: execution?.kind === "dispatch" ? execution.to_state : null
|
|
1520
|
+
};
|
|
1521
|
+
case "recovering":
|
|
1522
|
+
case "needs-attention": {
|
|
1523
|
+
const operation = execution ? this.store.findUnresolvedOperation(execution.execution_id) : void 0;
|
|
1524
|
+
if (!execution || !operation) {
|
|
1525
|
+
return {
|
|
1526
|
+
kind: "running",
|
|
1527
|
+
requestId: null,
|
|
1528
|
+
transition: null,
|
|
1529
|
+
destination: null
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
const work = this.work(execution, operation);
|
|
1533
|
+
return row.status === "recovering" ? { kind: "recovering", work } : { kind: "needs-attention", work, reason: row.status_reason ?? "" };
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
work(execution, operation) {
|
|
1538
|
+
return Object.freeze({
|
|
1539
|
+
operationId: operation.operation_id,
|
|
1540
|
+
requestId: execution.request_id,
|
|
1541
|
+
callback: Object.freeze(callbackRef(operation)),
|
|
1542
|
+
strategy: operation.strategy,
|
|
1543
|
+
attempts: operation.attempts,
|
|
1544
|
+
lastError: operation.last_error,
|
|
1545
|
+
nextAttemptAt: operation.next_attempt_at
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
currentWork(instanceId2) {
|
|
1549
|
+
const row = this.requireInstanceRow(instanceId2);
|
|
1550
|
+
if (row.current_execution_id === null) {
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
const execution = this.store.getExecution(row.current_execution_id);
|
|
1554
|
+
const operation = execution ? this.store.findUnresolvedOperation(execution.execution_id) : void 0;
|
|
1555
|
+
return execution && operation ? this.work(execution, operation) : null;
|
|
1556
|
+
}
|
|
1557
|
+
assertStoreOpen() {
|
|
1558
|
+
if (!this.store.isOpen) {
|
|
1559
|
+
throw new RuntimeClosedError("The durable runtime is closed");
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
history(instanceId2, query = {}) {
|
|
1563
|
+
this.assertStoreOpen();
|
|
1564
|
+
const limit = clampLimit(query.limit, 100, 1e3);
|
|
1565
|
+
const afterSequence = Math.max(0, Math.floor(query.afterSequence ?? 0));
|
|
1566
|
+
const rows = this.store.listHistory(instanceId2, {
|
|
1567
|
+
afterSequence,
|
|
1568
|
+
limit: limit + 1
|
|
1569
|
+
});
|
|
1570
|
+
const events = rows.slice(0, limit).map(
|
|
1571
|
+
(row) => Object.freeze({
|
|
1572
|
+
sequence: row.sequence,
|
|
1573
|
+
at: row.at,
|
|
1574
|
+
type: row.type,
|
|
1575
|
+
requestId: row.request_id,
|
|
1576
|
+
operationId: row.operation_id,
|
|
1577
|
+
attemptId: row.attempt_id,
|
|
1578
|
+
data: parseJson(row.data_json)
|
|
1579
|
+
})
|
|
1580
|
+
);
|
|
1581
|
+
return Object.freeze({
|
|
1582
|
+
events: Object.freeze(events),
|
|
1583
|
+
nextAfterSequence: rows.length > limit ? events[events.length - 1]?.sequence ?? null : null
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
listInstances(query = {}) {
|
|
1587
|
+
this.assertStoreOpen();
|
|
1588
|
+
const limit = clampLimit(query.limit, 50, 500);
|
|
1589
|
+
const statuses = query.status === void 0 ? null : Array.isArray(query.status) ? query.status : [query.status];
|
|
1590
|
+
const rows = this.store.listInstances({
|
|
1591
|
+
flowName: query.flowName ?? null,
|
|
1592
|
+
statuses,
|
|
1593
|
+
afterInstanceId: query.after ?? null,
|
|
1594
|
+
limit: limit + 1
|
|
1595
|
+
});
|
|
1596
|
+
const instances = rows.slice(0, limit).map(
|
|
1597
|
+
(row) => Object.freeze({
|
|
1598
|
+
instanceId: row.instance_id,
|
|
1599
|
+
flowName: row.chart_name,
|
|
1600
|
+
state: row.state,
|
|
1601
|
+
// Listings hold roots only, which never exit.
|
|
1602
|
+
status: row.status,
|
|
1603
|
+
statusReason: row.status_reason,
|
|
1604
|
+
revision: row.revision,
|
|
1605
|
+
createdAt: row.created_at,
|
|
1606
|
+
updatedAt: row.updated_at
|
|
1607
|
+
})
|
|
1608
|
+
);
|
|
1609
|
+
return Object.freeze({
|
|
1610
|
+
instances: Object.freeze(instances),
|
|
1611
|
+
next: rows.length > limit ? instances[instances.length - 1]?.instanceId ?? null : null
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
/** SQLite lock contention can clear without application intervention; other faults stay visible. */
|
|
1615
|
+
isTransientInfrastructureError(error) {
|
|
1616
|
+
if (typeof error !== "object" || error === null) return false;
|
|
1617
|
+
const code = "code" in error ? error.code : void 0;
|
|
1618
|
+
const number = "errcode" in error ? error.errcode : void 0;
|
|
1619
|
+
return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || typeof number === "number" && [5, 6].includes(number & 255);
|
|
1620
|
+
}
|
|
1621
|
+
/** Records an infrastructure stall and tells observers, without letting a broken store throw. */
|
|
1622
|
+
markStalled(instance, error) {
|
|
1623
|
+
instance.stall = Object.freeze({
|
|
1624
|
+
error: errorMessage(error),
|
|
1625
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1626
|
+
});
|
|
1627
|
+
try {
|
|
1628
|
+
this.notify(instance);
|
|
1629
|
+
} catch {
|
|
1630
|
+
}
|
|
1631
|
+
if (!this.closing && instance.timer === null && this.isTransientInfrastructureError(error) && instance.infrastructureRetries < RECOVERY_DELAYS_MS.length) {
|
|
1632
|
+
const delay = RECOVERY_DELAYS_MS[instance.infrastructureRetries++];
|
|
1633
|
+
instance.timer = setTimeout(() => {
|
|
1634
|
+
instance.timer = null;
|
|
1635
|
+
if (this.closing || !instance.stalled) return;
|
|
1636
|
+
try {
|
|
1637
|
+
this.startRecovery(instance);
|
|
1638
|
+
} catch (failure) {
|
|
1639
|
+
this.markStalled(instance, failure);
|
|
1640
|
+
this.reportRecoveryError(failure);
|
|
1641
|
+
}
|
|
1642
|
+
}, delay);
|
|
1643
|
+
instance.timer.unref?.();
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
/** Explicit restart after a stall (ADR 0076). Returns false when there was nothing to restart. */
|
|
1647
|
+
restartRecovery(instance) {
|
|
1648
|
+
this.assertOpen();
|
|
1649
|
+
if (instance.stall === null || instance.activeExecutionId !== null) {
|
|
1650
|
+
return false;
|
|
1651
|
+
}
|
|
1652
|
+
this.clearTimer(instance);
|
|
1653
|
+
instance.infrastructureRetries = 0;
|
|
1654
|
+
this.startRecovery(instance);
|
|
1655
|
+
this.notify(instance);
|
|
1656
|
+
return true;
|
|
1657
|
+
}
|
|
1658
|
+
/** Runtime-wide snapshot listeners (companion tools). */
|
|
1659
|
+
runtimeListeners = /* @__PURE__ */ new Set();
|
|
1660
|
+
/** Delivers the current snapshot; listener failures never affect execution (ADR 0059). */
|
|
1661
|
+
notify(instance) {
|
|
1662
|
+
if (instance.scope !== null) {
|
|
1663
|
+
const root = this.instances.get(instance.scope.rootId);
|
|
1664
|
+
if (root) this.notify(root);
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (instance.listeners.size === 0 && this.runtimeListeners.size === 0) {
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
this.store.afterCommit(() => this.deliver(instance));
|
|
1671
|
+
}
|
|
1672
|
+
deliver(instance) {
|
|
1673
|
+
if (!this.store.isOpen) {
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
let snapshot;
|
|
1677
|
+
try {
|
|
1678
|
+
snapshot = this.snapshot(instance);
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
this.reportObserverError(error);
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
const listeners = [...instance.listeners, ...this.runtimeListeners];
|
|
1684
|
+
queueMicrotask(() => {
|
|
1685
|
+
for (const listener of listeners) {
|
|
1686
|
+
try {
|
|
1687
|
+
const result = listener(snapshot);
|
|
1688
|
+
if (isThenable(result)) {
|
|
1689
|
+
result.then(
|
|
1690
|
+
void 0,
|
|
1691
|
+
(error) => this.reportObserverError(error)
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
this.reportObserverError(error);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
reportObserverError(error) {
|
|
1701
|
+
try {
|
|
1702
|
+
this.onObserverError(error);
|
|
1703
|
+
} catch {
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
reportRecoveryError(error) {
|
|
1707
|
+
try {
|
|
1708
|
+
this.onRecoveryError(error);
|
|
1709
|
+
} catch {
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
record(instanceId2, type, data, refs = {}) {
|
|
1713
|
+
this.store.appendHistory({
|
|
1714
|
+
instance_id: instanceId2,
|
|
1715
|
+
type,
|
|
1716
|
+
request_id: refs.requestId ?? null,
|
|
1717
|
+
operation_id: refs.operationId ?? null,
|
|
1718
|
+
attempt_id: refs.attemptId ?? null,
|
|
1719
|
+
data_json: canonicalJson(data)
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
requireInstanceRow(instanceId2) {
|
|
1723
|
+
const row = this.store.getInstance(instanceId2);
|
|
1724
|
+
if (!row) {
|
|
1725
|
+
throw new Error(`Durable instance ${instanceId2} is missing from storage`);
|
|
1726
|
+
}
|
|
1727
|
+
return row;
|
|
1728
|
+
}
|
|
1729
|
+
// --- execution tracking --------------------------------------------------
|
|
1730
|
+
/** Marks the instance busy while `work` runs and lets close() wait for it. */
|
|
1731
|
+
track(instance, executionId, work) {
|
|
1732
|
+
instance.activeExecutionId = executionId;
|
|
1733
|
+
const promise = work().then((result) => {
|
|
1734
|
+
instance.infrastructureRetries = 0;
|
|
1735
|
+
return result;
|
|
1736
|
+
}).catch((error) => {
|
|
1737
|
+
this.markStalled(instance, error);
|
|
1738
|
+
throw error;
|
|
1739
|
+
}).finally(() => {
|
|
1740
|
+
instance.activeExecutionId = null;
|
|
1741
|
+
this.active.delete(promise);
|
|
1742
|
+
if (instance.flow.root.composite) this.scheduleIdle(instance);
|
|
1743
|
+
});
|
|
1744
|
+
this.active.add(promise);
|
|
1745
|
+
return promise;
|
|
1746
|
+
}
|
|
1747
|
+
/** The flow core executing one graph of a root definition, with its flow's dependencies. */
|
|
1748
|
+
flowCoreFor(rootFlow, graphKey) {
|
|
1749
|
+
const root = rootFlow.root;
|
|
1750
|
+
if (graphKey === root.key) return rootFlow;
|
|
1751
|
+
let cores = this.graphCores.get(root);
|
|
1752
|
+
if (!cores) {
|
|
1753
|
+
cores = /* @__PURE__ */ new Map();
|
|
1754
|
+
this.graphCores.set(root, cores);
|
|
1755
|
+
}
|
|
1756
|
+
let core = cores.get(graphKey);
|
|
1757
|
+
if (!core) {
|
|
1758
|
+
const graph = root.graphs.get(graphKey);
|
|
1759
|
+
if (!graph) {
|
|
1760
|
+
throw new Error(
|
|
1761
|
+
`Graph ${graphKey} is missing from definition ${root.flowName}`
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
core = {
|
|
1765
|
+
definition: graph,
|
|
1766
|
+
root,
|
|
1767
|
+
dependencies: graph.flowName === root.flowName ? rootFlow.dependencies : this.dependenciesOf(graph.flowName)
|
|
1768
|
+
};
|
|
1769
|
+
cores.set(graphKey, core);
|
|
1770
|
+
}
|
|
1771
|
+
return core;
|
|
1772
|
+
}
|
|
1773
|
+
/** Loaded core of a scope row, created when first needed (ADR 0096). */
|
|
1774
|
+
scopeCore(rootFlow, row) {
|
|
1775
|
+
const cached = this.instances.get(row.instance_id);
|
|
1776
|
+
if (cached) return cached;
|
|
1777
|
+
const core = new InstanceCore(
|
|
1778
|
+
row.instance_id,
|
|
1779
|
+
this.flowCoreFor(rootFlow, row.graph ?? rootFlow.root.key),
|
|
1780
|
+
Object.freeze({
|
|
1781
|
+
rootId: row.root_instance_id ?? row.instance_id,
|
|
1782
|
+
parentId: row.parent_instance_id ?? "",
|
|
1783
|
+
path: row.scope_path ?? "",
|
|
1784
|
+
segment: row.segment ?? "",
|
|
1785
|
+
activationId: row.activation_id ?? "",
|
|
1786
|
+
memberKey: row.member_key,
|
|
1787
|
+
input: row.input_json === null ? null : parseJson(row.input_json)
|
|
1788
|
+
})
|
|
1789
|
+
);
|
|
1790
|
+
this.instances.set(row.instance_id, core);
|
|
1791
|
+
return core;
|
|
1792
|
+
}
|
|
1793
|
+
idleQueued = /* @__PURE__ */ new Set();
|
|
1794
|
+
/** Runs completion routing and resumes a waiting parent exit after the current step. */
|
|
1795
|
+
scheduleIdle(instance) {
|
|
1796
|
+
if (this.closing || this.idleQueued.has(instance)) return;
|
|
1797
|
+
this.idleQueued.add(instance);
|
|
1798
|
+
queueMicrotask(() => {
|
|
1799
|
+
this.idleQueued.delete(instance);
|
|
1800
|
+
if (this.closing || !this.store.isOpen) return;
|
|
1801
|
+
try {
|
|
1802
|
+
this.onIdle(instance);
|
|
1803
|
+
} catch (error) {
|
|
1804
|
+
this.markStalled(instance, error);
|
|
1805
|
+
this.reportRecoveryError(error);
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
// --- stepping ------------------------------------------------------------
|
|
1810
|
+
/** Runs the execution from its checkpointed step until it finishes or suspends. */
|
|
1811
|
+
async advance(instance, executionId) {
|
|
1812
|
+
const { definition } = instance.flow;
|
|
1813
|
+
for (; ; ) {
|
|
1814
|
+
const execution = this.requireExecution(executionId);
|
|
1815
|
+
if (execution.status !== "running") {
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
const step = execution.step;
|
|
1819
|
+
switch (step) {
|
|
1820
|
+
case "resolve-start-state": {
|
|
1821
|
+
const input = parseJson(execution.input_json);
|
|
1822
|
+
const resolver = definition.resolveStartState;
|
|
1823
|
+
let target2 = input.startState ?? definition.initialState.name;
|
|
1824
|
+
if (input.startState === null && resolver) {
|
|
1825
|
+
const outcome = await this.runStep(
|
|
1826
|
+
instance,
|
|
1827
|
+
execution,
|
|
1828
|
+
"resolve-start-state"
|
|
1829
|
+
);
|
|
1830
|
+
if (outcome.kind === "suspended") {
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
target2 = outcome.result;
|
|
1834
|
+
}
|
|
1835
|
+
this.store.updateExecution(executionId, {
|
|
1836
|
+
to_state: target2,
|
|
1837
|
+
step: "commit"
|
|
1838
|
+
});
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
case "commit": {
|
|
1842
|
+
const target2 = this.requireState(definition, execution.to_state);
|
|
1843
|
+
this.store.transaction(() => {
|
|
1844
|
+
this.store.updateInstance(instance.id, {
|
|
1845
|
+
state: target2.name
|
|
1846
|
+
});
|
|
1847
|
+
this.store.updateExecution(executionId, { step: "on-enter" });
|
|
1848
|
+
this.record(
|
|
1849
|
+
instance.id,
|
|
1850
|
+
"state-committed",
|
|
1851
|
+
{ from: execution.from_state, to: target2.name },
|
|
1852
|
+
{ requestId: execution.request_id }
|
|
1853
|
+
);
|
|
1854
|
+
if (target2.final) {
|
|
1855
|
+
this.finishInTransaction(instance, execution, "applied");
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
this.notify(instance);
|
|
1859
|
+
if (target2.final) {
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
case "guard": {
|
|
1865
|
+
const outcome = await this.runStep(instance, execution, "guard");
|
|
1866
|
+
if (outcome.kind === "suspended") {
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
if (outcome.result === false) {
|
|
1870
|
+
this.finish(instance, execution, "blocked");
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
this.store.updateExecution(executionId, { step: "exit-children" });
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
case "exit-children": {
|
|
1877
|
+
if (await this.exitChildren(instance, execution) === "waiting") {
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
this.store.updateExecution(executionId, { step: "on-exit" });
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
case "on-transition": {
|
|
1884
|
+
const outcome = await this.runStep(instance, execution, step);
|
|
1885
|
+
if (outcome.kind === "suspended") {
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const transition = this.requireTransition(definition, execution);
|
|
1889
|
+
const destination = isBranching(transition) ? outcome.result : transition.destinations[0].name;
|
|
1890
|
+
this.store.updateExecution(executionId, {
|
|
1891
|
+
to_state: destination,
|
|
1892
|
+
step: "commit"
|
|
1893
|
+
});
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
case "on-exit":
|
|
1897
|
+
case "on-enter": {
|
|
1898
|
+
const outcome = await this.runStep(instance, execution, step);
|
|
1899
|
+
if (outcome.kind === "suspended") {
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
this.store.updateExecution(executionId, {
|
|
1903
|
+
step: execution.kind === "exit" ? "done" : nextStep(step)
|
|
1904
|
+
});
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
case "children": {
|
|
1908
|
+
const target2 = definition.statesByName.get(execution.to_state ?? "");
|
|
1909
|
+
if (target2?.composition?.children) {
|
|
1910
|
+
const outcome = await this.runStep(instance, execution, "children");
|
|
1911
|
+
if (outcome.kind === "suspended") {
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
this.store.updateExecution(executionId, {
|
|
1916
|
+
step: "activate-children"
|
|
1917
|
+
});
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
case "activate-children": {
|
|
1921
|
+
const created = this.activateChildren(instance, execution);
|
|
1922
|
+
await this.enterChildren(instance, created);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
case "done":
|
|
1926
|
+
this.finish(instance, execution, "applied");
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
finish(instance, execution, status) {
|
|
1932
|
+
this.store.transaction(
|
|
1933
|
+
() => this.finishInTransaction(instance, execution, status)
|
|
1934
|
+
);
|
|
1935
|
+
this.notify(instance);
|
|
1936
|
+
}
|
|
1937
|
+
/** Ends the execution; an instance in a FinalState becomes `completed`. Runs in a transaction. */
|
|
1938
|
+
finishInTransaction(instance, execution, status) {
|
|
1939
|
+
const row = this.requireInstanceRow(instance.id);
|
|
1940
|
+
if (execution.kind === "exit") {
|
|
1941
|
+
this.store.updateExecution(execution.execution_id, { status });
|
|
1942
|
+
this.store.updateScope(instance.id, {
|
|
1943
|
+
active: 0,
|
|
1944
|
+
exit_reason: "exited"
|
|
1945
|
+
});
|
|
1946
|
+
this.store.updateInstance(instance.id, {
|
|
1947
|
+
status: "exited",
|
|
1948
|
+
status_reason: null,
|
|
1949
|
+
current_execution_id: null
|
|
1950
|
+
});
|
|
1951
|
+
this.recordScopeEvent(instance, "scope-exited", { state: row.state });
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
const completed = status === "applied" && instance.flow.definition.statesByName.get(row.state)?.final === true;
|
|
1955
|
+
this.store.updateExecution(execution.execution_id, { status });
|
|
1956
|
+
this.store.updateInstance(instance.id, {
|
|
1957
|
+
status: completed ? "completed" : "ready",
|
|
1958
|
+
status_reason: null,
|
|
1959
|
+
current_execution_id: null
|
|
1960
|
+
});
|
|
1961
|
+
this.record(
|
|
1962
|
+
instance.id,
|
|
1963
|
+
status === "applied" ? "execution-applied" : "execution-blocked",
|
|
1964
|
+
{
|
|
1965
|
+
kind: execution.kind,
|
|
1966
|
+
transition: execution.transition,
|
|
1967
|
+
...status === "applied" ? { destination: row.state } : {}
|
|
1968
|
+
},
|
|
1969
|
+
{ requestId: execution.request_id }
|
|
1970
|
+
);
|
|
1971
|
+
if (completed) {
|
|
1972
|
+
if (instance.scope !== null) {
|
|
1973
|
+
this.recordScopeEvent(instance, "scope-completed", {
|
|
1974
|
+
state: row.state
|
|
1975
|
+
});
|
|
1976
|
+
} else {
|
|
1977
|
+
this.record(
|
|
1978
|
+
instance.id,
|
|
1979
|
+
"instance-completed",
|
|
1980
|
+
{ state: row.state },
|
|
1981
|
+
{ requestId: execution.request_id }
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
/** Records a composition event in the root's history, naming the scope. */
|
|
1987
|
+
recordScopeEvent(instance, type, data) {
|
|
1988
|
+
const scope = instance.scope;
|
|
1989
|
+
if (scope === null) return;
|
|
1990
|
+
this.record(scope.rootId, type, {
|
|
1991
|
+
path: scope.path,
|
|
1992
|
+
activationId: scope.activationId,
|
|
1993
|
+
memberKey: scope.memberKey,
|
|
1994
|
+
...data
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
invocation(instance, execution, kind) {
|
|
1998
|
+
const { definition, dependencies } = instance.flow;
|
|
1999
|
+
const flowName = definition.flowName;
|
|
2000
|
+
const input = instance.scope?.input ?? null;
|
|
2001
|
+
if (kind === "resolve-start-state") {
|
|
2002
|
+
return {
|
|
2003
|
+
callback: definition.resolveStartState,
|
|
2004
|
+
context: {
|
|
2005
|
+
flowName,
|
|
2006
|
+
initialStateName: definition.initialState.name,
|
|
2007
|
+
dependencies,
|
|
2008
|
+
input
|
|
2009
|
+
},
|
|
2010
|
+
args: []
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (execution.kind === "exit") {
|
|
2014
|
+
const state = this.requireState(definition, execution.from_state);
|
|
2015
|
+
return {
|
|
2016
|
+
callback: kind === "on-exit" ? state.onExit : null,
|
|
2017
|
+
context: {
|
|
2018
|
+
flowName,
|
|
2019
|
+
stateName: state.name,
|
|
2020
|
+
transitionName: PARENT_EXIT_TRANSITION_NAME,
|
|
2021
|
+
dependencies,
|
|
2022
|
+
input
|
|
2023
|
+
},
|
|
2024
|
+
args: []
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
if (kind === "children") {
|
|
2028
|
+
const target2 = this.requireState(definition, execution.to_state);
|
|
2029
|
+
return {
|
|
2030
|
+
callback: target2.composition?.children ?? null,
|
|
2031
|
+
context: {
|
|
2032
|
+
flowName,
|
|
2033
|
+
stateName: target2.name,
|
|
2034
|
+
transitionName: execution.transition ?? "default",
|
|
2035
|
+
dependencies,
|
|
2036
|
+
input
|
|
2037
|
+
},
|
|
2038
|
+
// The entering Transition's payload, for example the files of a batch (ADR 0096).
|
|
2039
|
+
args: [
|
|
2040
|
+
execution.kind === "dispatch" ? parseJson(execution.input_json).payload : null
|
|
2041
|
+
]
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
if (execution.kind === "create") {
|
|
2045
|
+
const target2 = this.requireState(definition, execution.to_state);
|
|
2046
|
+
const isInitial = target2.id === definition.initialState.id;
|
|
2047
|
+
return {
|
|
2048
|
+
// Restoring into a non-initial state does not run onEnter (ADR 0035).
|
|
2049
|
+
callback: isInitial ? target2.onEnter : null,
|
|
2050
|
+
context: {
|
|
2051
|
+
flowName,
|
|
2052
|
+
stateName: target2.name,
|
|
2053
|
+
transitionName: "default",
|
|
2054
|
+
dependencies,
|
|
2055
|
+
input
|
|
2056
|
+
},
|
|
2057
|
+
args: []
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
const transition = this.requireTransition(definition, execution);
|
|
2061
|
+
const payload = parseJson(execution.input_json).payload;
|
|
2062
|
+
switch (kind) {
|
|
2063
|
+
case "guard":
|
|
2064
|
+
case "on-transition":
|
|
2065
|
+
return {
|
|
2066
|
+
callback: kind === "guard" ? transition.guard : transition.onTransition,
|
|
2067
|
+
context: {
|
|
2068
|
+
flowName,
|
|
2069
|
+
fromState: transition.from.name,
|
|
2070
|
+
// Allowed choices, not a selection: the destination is unknown until onTransition returns.
|
|
2071
|
+
destinations: Object.freeze(
|
|
2072
|
+
transition.destinations.map((state) => state.name)
|
|
2073
|
+
),
|
|
2074
|
+
transitionName: transition.name,
|
|
2075
|
+
dependencies,
|
|
2076
|
+
input
|
|
2077
|
+
},
|
|
2078
|
+
args: [payload]
|
|
2079
|
+
};
|
|
2080
|
+
case "on-exit":
|
|
2081
|
+
return {
|
|
2082
|
+
callback: transition.from.onExit,
|
|
2083
|
+
context: {
|
|
2084
|
+
flowName,
|
|
2085
|
+
stateName: transition.from.name,
|
|
2086
|
+
transitionName: transition.name,
|
|
2087
|
+
dependencies,
|
|
2088
|
+
input
|
|
2089
|
+
},
|
|
2090
|
+
args: []
|
|
2091
|
+
};
|
|
2092
|
+
case "on-enter": {
|
|
2093
|
+
const target2 = this.requireState(definition, execution.to_state);
|
|
2094
|
+
return {
|
|
2095
|
+
callback: target2.onEnter,
|
|
2096
|
+
context: {
|
|
2097
|
+
flowName,
|
|
2098
|
+
stateName: target2.name,
|
|
2099
|
+
transitionName: transition.name,
|
|
2100
|
+
dependencies,
|
|
2101
|
+
input
|
|
2102
|
+
},
|
|
2103
|
+
args: []
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
requireTransition(definition, execution) {
|
|
2109
|
+
const transition = definition.transitionsByName.get(
|
|
2110
|
+
execution.transition ?? ""
|
|
2111
|
+
);
|
|
2112
|
+
if (!transition) {
|
|
2113
|
+
throw new Error(
|
|
2114
|
+
`Transition ${execution.transition} is missing from definition ${definition.flowName}`
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
2117
|
+
return transition;
|
|
2118
|
+
}
|
|
2119
|
+
requireState(definition, name) {
|
|
2120
|
+
const state = definition.statesByName.get(name ?? "");
|
|
2121
|
+
if (!state) {
|
|
2122
|
+
throw new Error(
|
|
2123
|
+
`State ${name} is missing from definition ${definition.flowName}`
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
return state;
|
|
2127
|
+
}
|
|
2128
|
+
/** Returns a checkpointed result, or creates the operation and runs it once. */
|
|
2129
|
+
async runStep(instance, execution, kind) {
|
|
2130
|
+
const invocation = this.invocation(instance, execution, kind);
|
|
2131
|
+
if (!invocation.callback) {
|
|
2132
|
+
return { kind: "completed", result: null };
|
|
2133
|
+
}
|
|
2134
|
+
const existing = this.store.findOperation(execution.execution_id, kind);
|
|
2135
|
+
if (existing) {
|
|
2136
|
+
return existing.status === "completed" ? { kind: "completed", result: parseJson(existing.result_json) } : { kind: "suspended" };
|
|
2137
|
+
}
|
|
2138
|
+
const operation = {
|
|
2139
|
+
operation_id: mintOperationId(),
|
|
2140
|
+
execution_id: execution.execution_id,
|
|
2141
|
+
instance_id: instance.id,
|
|
2142
|
+
callback_kind: kind,
|
|
2143
|
+
callback_target: callbackTarget(invocation, kind),
|
|
2144
|
+
strategy: invocation.callback.strategy,
|
|
2145
|
+
status: "running",
|
|
2146
|
+
result_json: null,
|
|
2147
|
+
attempts: 0,
|
|
2148
|
+
attempt_limit: MAX_ATTEMPTS,
|
|
2149
|
+
last_error: null,
|
|
2150
|
+
last_cause: null,
|
|
2151
|
+
next_attempt_at: null
|
|
2152
|
+
};
|
|
2153
|
+
return this.attempt(instance, execution, operation, "run", {
|
|
2154
|
+
prelude: () => this.store.insertOperation(operation)
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* One attempt at an operation. The operation and attempt are persisted in one transaction
|
|
2159
|
+
* before user code runs, so a crash mid-callback is detected as interrupted on the next open
|
|
2160
|
+
* (ADR 0055, 0065). The attempt's result is settled in a second single transaction.
|
|
2161
|
+
*/
|
|
2162
|
+
async attempt(instance, execution, operation, mode, options = {}) {
|
|
2163
|
+
const invocation = this.invocation(
|
|
2164
|
+
instance,
|
|
2165
|
+
execution,
|
|
2166
|
+
operation.callback_kind
|
|
2167
|
+
);
|
|
2168
|
+
const callback = invocation.callback;
|
|
2169
|
+
if (!callback) {
|
|
2170
|
+
throw new Error(
|
|
2171
|
+
`Operation ${operation.operation_id} has no callback in the current definition`
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
const attemptNumber = operation.attempts + 1;
|
|
2175
|
+
const attemptId = mintAttemptId();
|
|
2176
|
+
this.store.transaction(() => {
|
|
2177
|
+
options.prelude?.();
|
|
2178
|
+
this.store.updateOperation(operation.operation_id, {
|
|
2179
|
+
status: "running",
|
|
2180
|
+
attempts: attemptNumber,
|
|
2181
|
+
next_attempt_at: null
|
|
2182
|
+
});
|
|
2183
|
+
this.store.insertAttempt({
|
|
2184
|
+
attempt_id: attemptId,
|
|
2185
|
+
operation_id: operation.operation_id,
|
|
2186
|
+
mode,
|
|
2187
|
+
number: attemptNumber
|
|
2188
|
+
});
|
|
2189
|
+
this.record(
|
|
2190
|
+
instance.id,
|
|
2191
|
+
"callback-started",
|
|
2192
|
+
{ callback: operation.callback_kind, mode, attempt: attemptNumber },
|
|
2193
|
+
{
|
|
2194
|
+
requestId: execution.request_id,
|
|
2195
|
+
operationId: operation.operation_id,
|
|
2196
|
+
attemptId
|
|
2197
|
+
}
|
|
2198
|
+
);
|
|
2199
|
+
});
|
|
2200
|
+
const refs = {
|
|
2201
|
+
requestId: execution.request_id,
|
|
2202
|
+
operationId: operation.operation_id,
|
|
2203
|
+
attemptId
|
|
2204
|
+
};
|
|
2205
|
+
const context = {
|
|
2206
|
+
...invocation.context,
|
|
2207
|
+
instanceId: instance.rootId,
|
|
2208
|
+
operation: Object.freeze({
|
|
2209
|
+
operationId: operation.operation_id,
|
|
2210
|
+
attemptId,
|
|
2211
|
+
attempt: attemptNumber
|
|
2212
|
+
}),
|
|
2213
|
+
// Scoped cancellation: a parent interrupt aborts only this scope (ADR 0095).
|
|
2214
|
+
signal: instance.scope === null ? this.abort.signal : AbortSignal.any([this.abort.signal, instance.abort.signal]),
|
|
2215
|
+
scope: instance.scope === null ? null : Object.freeze({
|
|
2216
|
+
path: instance.scope.path,
|
|
2217
|
+
activationId: instance.scope.activationId,
|
|
2218
|
+
memberKey: instance.scope.memberKey
|
|
2219
|
+
})
|
|
2220
|
+
};
|
|
2221
|
+
const current = this.requireOperation(operation.operation_id);
|
|
2222
|
+
if (mode === "run") {
|
|
2223
|
+
let value;
|
|
2224
|
+
try {
|
|
2225
|
+
value = await callback.run(context, ...invocation.args);
|
|
2226
|
+
} catch (error) {
|
|
2227
|
+
const cause = this.closing || instance.abort.signal.aborted ? "interrupted" : "failed";
|
|
2228
|
+
return this.store.transaction(() => {
|
|
2229
|
+
this.store.finishAttempt(attemptId, cause, errorMessage(error));
|
|
2230
|
+
return this.failed(
|
|
2231
|
+
instance,
|
|
2232
|
+
execution,
|
|
2233
|
+
current,
|
|
2234
|
+
cause,
|
|
2235
|
+
errorMessage(error),
|
|
2236
|
+
refs
|
|
2237
|
+
);
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
return this.store.transaction(() => {
|
|
2241
|
+
const validated = validateCallbackResult(
|
|
2242
|
+
instance.flow.definition,
|
|
2243
|
+
operation.callback_kind,
|
|
2244
|
+
value,
|
|
2245
|
+
execution.transition,
|
|
2246
|
+
execution.to_state
|
|
2247
|
+
);
|
|
2248
|
+
if (!validated.ok) {
|
|
2249
|
+
this.store.finishAttempt(
|
|
2250
|
+
attemptId,
|
|
2251
|
+
"invalid-result",
|
|
2252
|
+
validated.message
|
|
2253
|
+
);
|
|
2254
|
+
if (operation.callback_kind === "on-transition" && !this.isFenced(execution.execution_id)) {
|
|
2255
|
+
return this.failed(
|
|
2256
|
+
instance,
|
|
2257
|
+
execution,
|
|
2258
|
+
current,
|
|
2259
|
+
"failed",
|
|
2260
|
+
`Invalid callback result: ${validated.message}`,
|
|
2261
|
+
refs
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
return this.escalate(
|
|
2265
|
+
instance,
|
|
2266
|
+
execution,
|
|
2267
|
+
current,
|
|
2268
|
+
`Invalid callback result: ${validated.message}`,
|
|
2269
|
+
refs
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
if (this.isFenced(execution.execution_id)) {
|
|
2273
|
+
return this.settleLate(
|
|
2274
|
+
instance,
|
|
2275
|
+
execution,
|
|
2276
|
+
current,
|
|
2277
|
+
attemptId,
|
|
2278
|
+
validated.result,
|
|
2279
|
+
"late-result",
|
|
2280
|
+
refs
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
return this.complete(
|
|
2284
|
+
instance,
|
|
2285
|
+
execution,
|
|
2286
|
+
current,
|
|
2287
|
+
attemptId,
|
|
2288
|
+
validated.result,
|
|
2289
|
+
refs
|
|
2290
|
+
);
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
const reconcile = callback.reconcile;
|
|
2294
|
+
if (!reconcile) {
|
|
2295
|
+
throw new Error(
|
|
2296
|
+
`Operation ${operation.operation_id} has no reconcile function`
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
let decision;
|
|
2300
|
+
try {
|
|
2301
|
+
decision = await reconcile(
|
|
2302
|
+
{
|
|
2303
|
+
...context,
|
|
2304
|
+
recovery: Object.freeze({
|
|
2305
|
+
cause: operation.last_cause ?? "failed",
|
|
2306
|
+
lastError: operation.last_error
|
|
2307
|
+
})
|
|
2308
|
+
},
|
|
2309
|
+
...invocation.args
|
|
2310
|
+
);
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
return this.store.transaction(() => {
|
|
2313
|
+
this.store.finishAttempt(attemptId, "failed", errorMessage(error));
|
|
2314
|
+
this.store.updateOperation(current.operation_id, {
|
|
2315
|
+
last_error: errorMessage(error)
|
|
2316
|
+
});
|
|
2317
|
+
return this.scheduleOrEscalate(
|
|
2318
|
+
instance,
|
|
2319
|
+
execution,
|
|
2320
|
+
this.requireOperation(current.operation_id),
|
|
2321
|
+
refs,
|
|
2322
|
+
this.closing ? 0 : delayAfterAttempt(attemptNumber)
|
|
2323
|
+
);
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
const settled = this.store.transaction(
|
|
2327
|
+
() => this.settleReconcile(
|
|
2328
|
+
instance,
|
|
2329
|
+
execution,
|
|
2330
|
+
current,
|
|
2331
|
+
attemptId,
|
|
2332
|
+
decision,
|
|
2333
|
+
refs
|
|
2334
|
+
)
|
|
2335
|
+
);
|
|
2336
|
+
return settled === "replay" ? this.attempt(instance, execution, current, "run") : settled;
|
|
2337
|
+
}
|
|
2338
|
+
/** Applies one reconciliation decision (ADR 0064). Runs inside a transaction. */
|
|
2339
|
+
settleReconcile(instance, execution, operation, attemptId, decision, refs) {
|
|
2340
|
+
const kind = isRecord2(decision) ? decision.decision : void 0;
|
|
2341
|
+
switch (kind) {
|
|
2342
|
+
case "completed": {
|
|
2343
|
+
const validated = validateCallbackResult(
|
|
2344
|
+
instance.flow.definition,
|
|
2345
|
+
operation.callback_kind,
|
|
2346
|
+
decision.result,
|
|
2347
|
+
execution.transition,
|
|
2348
|
+
execution.to_state
|
|
2349
|
+
);
|
|
2350
|
+
if (!validated.ok) {
|
|
2351
|
+
this.store.finishAttempt(
|
|
2352
|
+
attemptId,
|
|
2353
|
+
"invalid-result",
|
|
2354
|
+
validated.message
|
|
2355
|
+
);
|
|
2356
|
+
return this.escalate(
|
|
2357
|
+
instance,
|
|
2358
|
+
execution,
|
|
2359
|
+
operation,
|
|
2360
|
+
`Invalid reconciliation result: ${validated.message}`,
|
|
2361
|
+
refs
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
if (this.isFenced(execution.execution_id)) {
|
|
2365
|
+
return this.settleLate(
|
|
2366
|
+
instance,
|
|
2367
|
+
execution,
|
|
2368
|
+
operation,
|
|
2369
|
+
attemptId,
|
|
2370
|
+
validated.result,
|
|
2371
|
+
"reconciled",
|
|
2372
|
+
refs
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
return this.complete(
|
|
2376
|
+
instance,
|
|
2377
|
+
execution,
|
|
2378
|
+
operation,
|
|
2379
|
+
attemptId,
|
|
2380
|
+
validated.result,
|
|
2381
|
+
refs
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
case "safe-to-retry":
|
|
2385
|
+
this.store.finishAttempt(attemptId, "safe-to-retry", null);
|
|
2386
|
+
if (this.isFenced(execution.execution_id)) {
|
|
2387
|
+
return this.escalate(
|
|
2388
|
+
instance,
|
|
2389
|
+
execution,
|
|
2390
|
+
operation,
|
|
2391
|
+
NO_REPLAY_REASON,
|
|
2392
|
+
refs
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
if (operation.attempts >= operation.attempt_limit) {
|
|
2396
|
+
return this.escalate(
|
|
2397
|
+
instance,
|
|
2398
|
+
execution,
|
|
2399
|
+
operation,
|
|
2400
|
+
exhaustedReason(operation),
|
|
2401
|
+
refs
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
return "replay";
|
|
2405
|
+
case "pending":
|
|
2406
|
+
this.store.finishAttempt(attemptId, "pending", null);
|
|
2407
|
+
return this.scheduleOrEscalate(
|
|
2408
|
+
instance,
|
|
2409
|
+
execution,
|
|
2410
|
+
operation,
|
|
2411
|
+
refs,
|
|
2412
|
+
delayAfterAttempt(operation.attempts)
|
|
2413
|
+
);
|
|
2414
|
+
case "needs-attention": {
|
|
2415
|
+
const reason = decision.reason;
|
|
2416
|
+
this.store.finishAttempt(attemptId, "needs-attention", null);
|
|
2417
|
+
return this.escalate(
|
|
2418
|
+
instance,
|
|
2419
|
+
execution,
|
|
2420
|
+
operation,
|
|
2421
|
+
typeof reason === "string" && reason.length > 0 ? reason : "Reconciliation requires attention",
|
|
2422
|
+
refs
|
|
2423
|
+
);
|
|
2424
|
+
}
|
|
2425
|
+
default:
|
|
2426
|
+
this.store.finishAttempt(attemptId, "invalid-decision", null);
|
|
2427
|
+
return this.escalate(
|
|
2428
|
+
instance,
|
|
2429
|
+
execution,
|
|
2430
|
+
operation,
|
|
2431
|
+
"Reconciliation returned an invalid decision",
|
|
2432
|
+
refs
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
complete(instance, execution, operation, attemptId, result, refs) {
|
|
2437
|
+
this.store.transaction(() => {
|
|
2438
|
+
this.checkpoint(instance, execution, operation, result);
|
|
2439
|
+
this.store.finishAttempt(attemptId, "completed", null);
|
|
2440
|
+
this.record(
|
|
2441
|
+
instance.id,
|
|
2442
|
+
"callback-completed",
|
|
2443
|
+
{ callback: operation.callback_kind, result },
|
|
2444
|
+
refs
|
|
2445
|
+
);
|
|
2446
|
+
});
|
|
2447
|
+
return { kind: "completed", result };
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Records a callback as completed and, in the same transaction, marks a suspended execution
|
|
2451
|
+
* running again, so a crash can never leave a completed callback whose execution never resumes.
|
|
2452
|
+
* Must run inside a transaction.
|
|
2453
|
+
*/
|
|
2454
|
+
checkpoint(instance, execution, operation, result) {
|
|
2455
|
+
this.store.updateOperation(operation.operation_id, {
|
|
2456
|
+
status: "completed",
|
|
2457
|
+
result_json: JSON.stringify(result),
|
|
2458
|
+
next_attempt_at: null
|
|
2459
|
+
});
|
|
2460
|
+
const status = this.requireExecution(execution.execution_id).status;
|
|
2461
|
+
if (status !== "running" && status !== "interrupted") {
|
|
2462
|
+
this.store.updateExecution(execution.execution_id, { status: "running" });
|
|
2463
|
+
this.store.updateInstance(instance.id, {
|
|
2464
|
+
status: "running",
|
|
2465
|
+
status_reason: null
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
/** Whether a parent Transition interrupted this execution (ADR 0095). */
|
|
2470
|
+
isFenced(executionId) {
|
|
2471
|
+
return this.requireExecution(executionId).status === "interrupted";
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* Records the established outcome of interrupted work (ADR 0095, 0096): the operation is
|
|
2475
|
+
* completed as evidence, the application is told durably, and the exited graph never advances.
|
|
2476
|
+
* Must run inside a transaction.
|
|
2477
|
+
*/
|
|
2478
|
+
settleLate(instance, execution, operation, attemptId, result, source, refs) {
|
|
2479
|
+
this.store.updateOperation(operation.operation_id, {
|
|
2480
|
+
status: "completed",
|
|
2481
|
+
result_json: JSON.stringify(result),
|
|
2482
|
+
next_attempt_at: null
|
|
2483
|
+
});
|
|
2484
|
+
if (attemptId !== null) {
|
|
2485
|
+
this.store.finishAttempt(attemptId, "completed", null);
|
|
2486
|
+
}
|
|
2487
|
+
this.record(
|
|
2488
|
+
instance.id,
|
|
2489
|
+
"callback-completed",
|
|
2490
|
+
{ callback: operation.callback_kind, result, late: true },
|
|
2491
|
+
refs
|
|
2492
|
+
);
|
|
2493
|
+
this.recordLateOutcome(instance, operation, source, result);
|
|
2494
|
+
this.releaseRetained(instance, execution);
|
|
2495
|
+
this.notify(instance);
|
|
2496
|
+
return { kind: "suspended" };
|
|
2497
|
+
}
|
|
2498
|
+
/** Durable report of a late outcome for the application (ADR 0096). Inside a transaction. */
|
|
2499
|
+
recordLateOutcome(instance, operation, source, result) {
|
|
2500
|
+
const scope = instance.scope;
|
|
2501
|
+
if (scope === null) return;
|
|
2502
|
+
this.store.insertLateOutcome({
|
|
2503
|
+
root_instance_id: scope.rootId,
|
|
2504
|
+
scope_instance_id: instance.id,
|
|
2505
|
+
scope_path: scope.path,
|
|
2506
|
+
activation_id: scope.activationId,
|
|
2507
|
+
member_key: scope.memberKey,
|
|
2508
|
+
operation_id: operation.operation_id,
|
|
2509
|
+
callback_kind: operation.callback_kind,
|
|
2510
|
+
callback_target: operation.callback_target,
|
|
2511
|
+
source,
|
|
2512
|
+
result_json: canonicalJson(result)
|
|
2513
|
+
});
|
|
2514
|
+
this.recordScopeEvent(instance, "late-outcome", {
|
|
2515
|
+
operationId: operation.operation_id,
|
|
2516
|
+
callback: operation.callback_kind,
|
|
2517
|
+
source,
|
|
2518
|
+
result
|
|
2519
|
+
});
|
|
2520
|
+
if (this.lateListeners.size > 0) {
|
|
2521
|
+
this.store.afterCommit(() => {
|
|
2522
|
+
for (const outcome of this.pendingLateOutcomes(scope.rootId)) {
|
|
2523
|
+
if (outcome.operationId !== operation.operation_id) continue;
|
|
2524
|
+
for (const listener of [...this.lateListeners]) {
|
|
2525
|
+
try {
|
|
2526
|
+
const value = listener(outcome);
|
|
2527
|
+
if (isThenable(value)) {
|
|
2528
|
+
value.then(
|
|
2529
|
+
void 0,
|
|
2530
|
+
(error) => this.reportObserverError(error)
|
|
2531
|
+
);
|
|
2532
|
+
}
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
this.reportObserverError(error);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
/** Unacknowledged late outcomes, oldest first. */
|
|
2542
|
+
pendingLateOutcomes(rootId) {
|
|
2543
|
+
return this.store.listPendingLateOutcomes(rootId).map(
|
|
2544
|
+
(row) => Object.freeze({
|
|
2545
|
+
sequence: row.sequence,
|
|
2546
|
+
instanceId: row.root_instance_id,
|
|
2547
|
+
path: row.scope_path,
|
|
2548
|
+
activationId: row.activation_id,
|
|
2549
|
+
memberKey: row.member_key,
|
|
2550
|
+
operationId: row.operation_id,
|
|
2551
|
+
callback: Object.freeze(
|
|
2552
|
+
callbackRef({
|
|
2553
|
+
callback_kind: row.callback_kind,
|
|
2554
|
+
callback_target: row.callback_target
|
|
2555
|
+
})
|
|
2556
|
+
),
|
|
2557
|
+
source: row.source,
|
|
2558
|
+
result: parseJson(row.result_json),
|
|
2559
|
+
recordedAt: row.recorded_at
|
|
2560
|
+
})
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
/** An exited scope with no unresolved work left stops blocking deployment. In a transaction. */
|
|
2564
|
+
releaseRetained(instance, execution) {
|
|
2565
|
+
if (this.store.findUnresolvedOperation(execution.execution_id)) return;
|
|
2566
|
+
const row = this.store.getScope(instance.id);
|
|
2567
|
+
if (!row || row.active === 1) return;
|
|
2568
|
+
this.store.updateInstance(instance.id, {
|
|
2569
|
+
status: "exited",
|
|
2570
|
+
status_reason: null,
|
|
2571
|
+
current_execution_id: null
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
/** Applies the declared strategy after a failed or interrupted run (ADR 0046). */
|
|
2575
|
+
failed(instance, execution, operation, cause, message, refs) {
|
|
2576
|
+
return this.store.transaction(() => {
|
|
2577
|
+
this.store.updateOperation(operation.operation_id, {
|
|
2578
|
+
last_error: message,
|
|
2579
|
+
last_cause: cause
|
|
2580
|
+
});
|
|
2581
|
+
this.record(
|
|
2582
|
+
instance.id,
|
|
2583
|
+
cause === "interrupted" ? "recovery-interrupted" : "callback-failed",
|
|
2584
|
+
{ callback: operation.callback_kind, error: message },
|
|
2585
|
+
refs
|
|
2586
|
+
);
|
|
2587
|
+
return this.settleFailure(
|
|
2588
|
+
instance,
|
|
2589
|
+
execution,
|
|
2590
|
+
operation,
|
|
2591
|
+
cause,
|
|
2592
|
+
message,
|
|
2593
|
+
refs
|
|
2594
|
+
);
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
settleFailure(instance, execution, operation, cause, message, refs) {
|
|
2598
|
+
const current = this.requireOperation(operation.operation_id);
|
|
2599
|
+
if (this.isFenced(execution.execution_id) && current.strategy !== "reconcile") {
|
|
2600
|
+
return this.escalate(
|
|
2601
|
+
instance,
|
|
2602
|
+
execution,
|
|
2603
|
+
current,
|
|
2604
|
+
NO_REPLAY_REASON,
|
|
2605
|
+
refs
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
if (current.strategy === "manual") {
|
|
2609
|
+
return this.escalate(
|
|
2610
|
+
instance,
|
|
2611
|
+
execution,
|
|
2612
|
+
current,
|
|
2613
|
+
`Manual recovery required: ${message}`,
|
|
2614
|
+
refs
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
return this.scheduleOrEscalate(
|
|
2618
|
+
instance,
|
|
2619
|
+
execution,
|
|
2620
|
+
current,
|
|
2621
|
+
refs,
|
|
2622
|
+
cause === "interrupted" ? 0 : delayAfterAttempt(current.attempts)
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
scheduleOrEscalate(instance, execution, operation, refs, delayMs) {
|
|
2626
|
+
if (operation.attempts >= operation.attempt_limit) {
|
|
2627
|
+
return this.escalate(
|
|
2628
|
+
instance,
|
|
2629
|
+
execution,
|
|
2630
|
+
operation,
|
|
2631
|
+
exhaustedReason(operation),
|
|
2632
|
+
refs
|
|
2633
|
+
);
|
|
2634
|
+
}
|
|
2635
|
+
const nextAttemptAt = new Date(Date.now() + delayMs).toISOString();
|
|
2636
|
+
const fenced = this.isFenced(execution.execution_id);
|
|
2637
|
+
this.store.transaction(() => {
|
|
2638
|
+
this.store.updateOperation(operation.operation_id, {
|
|
2639
|
+
status: "recovering",
|
|
2640
|
+
next_attempt_at: nextAttemptAt
|
|
2641
|
+
});
|
|
2642
|
+
if (!fenced) {
|
|
2643
|
+
this.store.updateExecution(execution.execution_id, {
|
|
2644
|
+
status: "recovering"
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
this.store.updateInstance(instance.id, {
|
|
2648
|
+
status: "recovering",
|
|
2649
|
+
status_reason: null
|
|
2650
|
+
});
|
|
2651
|
+
this.record(
|
|
2652
|
+
instance.id,
|
|
2653
|
+
"recovery-scheduled",
|
|
2654
|
+
{ callback: operation.callback_kind, nextAttemptAt },
|
|
2655
|
+
refs
|
|
2656
|
+
);
|
|
2657
|
+
});
|
|
2658
|
+
this.scheduleTimer(instance, delayMs);
|
|
2659
|
+
this.notify(instance);
|
|
2660
|
+
return { kind: "suspended" };
|
|
2661
|
+
}
|
|
2662
|
+
escalate(instance, execution, operation, reason, refs) {
|
|
2663
|
+
const fenced = this.isFenced(execution.execution_id);
|
|
2664
|
+
this.store.transaction(() => {
|
|
2665
|
+
this.store.updateOperation(operation.operation_id, {
|
|
2666
|
+
status: "needs-attention",
|
|
2667
|
+
next_attempt_at: null
|
|
2668
|
+
});
|
|
2669
|
+
if (!fenced) {
|
|
2670
|
+
this.store.updateExecution(execution.execution_id, {
|
|
2671
|
+
status: "needs-attention"
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
this.store.updateInstance(instance.id, {
|
|
2675
|
+
status: "needs-attention",
|
|
2676
|
+
status_reason: reason
|
|
2677
|
+
});
|
|
2678
|
+
this.record(
|
|
2679
|
+
instance.id,
|
|
2680
|
+
"needs-attention",
|
|
2681
|
+
{ callback: operation.callback_kind, reason },
|
|
2682
|
+
refs
|
|
2683
|
+
);
|
|
2684
|
+
});
|
|
2685
|
+
this.notify(instance);
|
|
2686
|
+
return { kind: "suspended" };
|
|
2687
|
+
}
|
|
2688
|
+
// --- recovery scheduling -------------------------------------------------
|
|
2689
|
+
scheduleTimer(instance, delayMs) {
|
|
2690
|
+
this.store.afterCommit(() => this.armTimer(instance, delayMs));
|
|
2691
|
+
}
|
|
2692
|
+
armTimer(instance, delayMs) {
|
|
2693
|
+
this.clearTimer(instance);
|
|
2694
|
+
if (this.closing) {
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
const timer = setTimeout(() => {
|
|
2698
|
+
instance.timer = null;
|
|
2699
|
+
void this.recover(instance).catch((error) => {
|
|
2700
|
+
this.markStalled(instance, error);
|
|
2701
|
+
this.reportRecoveryError(error);
|
|
2702
|
+
});
|
|
2703
|
+
}, delayMs);
|
|
2704
|
+
timer.unref?.();
|
|
2705
|
+
instance.timer = timer;
|
|
2706
|
+
}
|
|
2707
|
+
clearTimer(instance) {
|
|
2708
|
+
if (instance.timer !== null) {
|
|
2709
|
+
clearTimeout(instance.timer);
|
|
2710
|
+
instance.timer = null;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
/** One scheduled automatic recovery attempt for the instance's unresolved operation. */
|
|
2714
|
+
async recover(instance) {
|
|
2715
|
+
if (this.closing) {
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
if (instance.activeExecutionId !== null) {
|
|
2719
|
+
this.scheduleTimer(instance, BUSY_RETRY_DELAY_MS);
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
const row = this.requireInstanceRow(instance.id);
|
|
2723
|
+
if (row.status !== "recovering" || row.current_execution_id === null) {
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
const execution = this.requireExecution(row.current_execution_id);
|
|
2727
|
+
const operation = this.store.findUnresolvedOperation(
|
|
2728
|
+
execution.execution_id
|
|
2729
|
+
);
|
|
2730
|
+
if (!operation || operation.status !== "recovering") {
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
await this.track(
|
|
2734
|
+
instance,
|
|
2735
|
+
execution.execution_id,
|
|
2736
|
+
() => this.recoverOnce(instance, execution, operation)
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
/**
|
|
2740
|
+
* One recovery attempt following the declared strategy (reconcile, or run again for
|
|
2741
|
+
* safe-replay and manual), then the execution's remaining steps if it completed.
|
|
2742
|
+
*/
|
|
2743
|
+
async recoverOnce(instance, execution, operation, prelude) {
|
|
2744
|
+
const mode = operation.strategy === "reconcile" ? "reconcile" : "run";
|
|
2745
|
+
if (mode === "run" && this.isFenced(execution.execution_id)) {
|
|
2746
|
+
this.escalate(instance, execution, operation, NO_REPLAY_REASON, {
|
|
2747
|
+
requestId: execution.request_id,
|
|
2748
|
+
operationId: operation.operation_id
|
|
2749
|
+
});
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
const outcome = await this.attempt(instance, execution, operation, mode, {
|
|
2753
|
+
prelude
|
|
2754
|
+
});
|
|
2755
|
+
if (outcome.kind === "completed" && !this.isFenced(execution.execution_id)) {
|
|
2756
|
+
this.notify(instance);
|
|
2757
|
+
await this.advance(instance, execution.execution_id);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
/**
|
|
2761
|
+
* Operator retry (ADR 0073): grants one more bounded attempt and commits it, the resumed
|
|
2762
|
+
* status and the audit entry together with the attempt start, so a crash cannot strand it.
|
|
2763
|
+
*/
|
|
2764
|
+
manualRetry(instance, execution, operation, audit) {
|
|
2765
|
+
return this.track(
|
|
2766
|
+
instance,
|
|
2767
|
+
execution.execution_id,
|
|
2768
|
+
() => this.recoverOnce(instance, execution, operation, () => {
|
|
2769
|
+
const fenced = this.isFenced(execution.execution_id);
|
|
2770
|
+
this.store.updateOperation(operation.operation_id, {
|
|
2771
|
+
// A reconcile check may conclude safe-to-retry, so it also gets the replay it permits,
|
|
2772
|
+
// except for interrupted work, which is only ever checked (ADR 0095).
|
|
2773
|
+
attempt_limit: operation.attempts + (operation.strategy === "reconcile" && !fenced ? 2 : 1)
|
|
2774
|
+
});
|
|
2775
|
+
if (!fenced) {
|
|
2776
|
+
this.store.updateExecution(execution.execution_id, {
|
|
2777
|
+
status: "running"
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
this.store.updateInstance(instance.id, {
|
|
2781
|
+
status: "running",
|
|
2782
|
+
status_reason: null
|
|
2783
|
+
});
|
|
2784
|
+
audit();
|
|
2785
|
+
})
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Starts recovery for an instance loaded from storage (ADR 0050), or restarts it after a
|
|
2790
|
+
* stall. Also repairs an execution whose callback completed but which never resumed.
|
|
2791
|
+
*/
|
|
2792
|
+
startRecovery(instance) {
|
|
2793
|
+
instance.stall = null;
|
|
2794
|
+
if (instance.activeExecutionId !== null) {
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
const row = this.requireInstanceRow(instance.id);
|
|
2798
|
+
if (row.status === "ready" || row.status === "terminated" || row.status === "completed" || row.status === "exited" || row.current_execution_id === null) {
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
const execution = this.requireExecution(row.current_execution_id);
|
|
2802
|
+
const operation = this.store.findUnresolvedOperation(
|
|
2803
|
+
execution.execution_id
|
|
2804
|
+
);
|
|
2805
|
+
if (!operation && execution.status === "interrupted") {
|
|
2806
|
+
this.store.transaction(() => this.releaseRetained(instance, execution));
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
if (operation?.status === "running") {
|
|
2810
|
+
this.store.transaction(() => {
|
|
2811
|
+
this.store.finishInterruptedAttempts(operation.operation_id);
|
|
2812
|
+
this.failed(
|
|
2813
|
+
instance,
|
|
2814
|
+
execution,
|
|
2815
|
+
operation,
|
|
2816
|
+
"interrupted",
|
|
2817
|
+
"The process stopped while the callback was running",
|
|
2818
|
+
{
|
|
2819
|
+
requestId: execution.request_id,
|
|
2820
|
+
operationId: operation.operation_id
|
|
2821
|
+
}
|
|
2822
|
+
);
|
|
2823
|
+
});
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (operation?.status === "recovering") {
|
|
2827
|
+
const due = operation.next_attempt_at ? Date.parse(operation.next_attempt_at) - Date.now() : 0;
|
|
2828
|
+
this.scheduleTimer(instance, Math.max(0, due));
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
if (operation) {
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
if (execution.status !== "running") {
|
|
2835
|
+
this.store.transaction(() => {
|
|
2836
|
+
this.store.updateExecution(execution.execution_id, {
|
|
2837
|
+
status: "running"
|
|
2838
|
+
});
|
|
2839
|
+
this.store.updateInstance(instance.id, {
|
|
2840
|
+
status: "running",
|
|
2841
|
+
status_reason: null
|
|
2842
|
+
});
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
void this.track(
|
|
2846
|
+
instance,
|
|
2847
|
+
execution.execution_id,
|
|
2848
|
+
() => this.advance(instance, execution.execution_id)
|
|
2849
|
+
).catch((error) => {
|
|
2850
|
+
this.markStalled(instance, error);
|
|
2851
|
+
this.reportRecoveryError(error);
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
// --- composition (ADR 0095, 0096) ------------------------------------------
|
|
2855
|
+
/** Every active descendant of a scope, inner before outer, later siblings first. */
|
|
2856
|
+
activeDescendants(instanceId2) {
|
|
2857
|
+
const result = [];
|
|
2858
|
+
const visit = (parentId) => {
|
|
2859
|
+
const children = this.store.listChildScopes(parentId).reverse();
|
|
2860
|
+
for (const child of children) {
|
|
2861
|
+
visit(child.instance_id);
|
|
2862
|
+
if (child.active === 1) result.push(child);
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
visit(instanceId2);
|
|
2866
|
+
return result;
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Whether a descendant has running or unresolved work: the `block` policy answers `busy`
|
|
2870
|
+
* (ADR 0095). Exited activations that only retain old work do not count.
|
|
2871
|
+
*/
|
|
2872
|
+
hasBusyDescendant(instance) {
|
|
2873
|
+
return this.activeDescendants(instance.id).some(
|
|
2874
|
+
(row) => row.status === "running" || row.status === "recovering" || row.status === "needs-attention" || (this.instances.get(row.instance_id)?.activeExecutionId ?? null) !== null
|
|
2875
|
+
);
|
|
2876
|
+
}
|
|
2877
|
+
/** Whether an ancestor scope has an execution in progress, which reserves this subtree. */
|
|
2878
|
+
ancestorBusy(instance) {
|
|
2879
|
+
for (let parentId = instance.scope?.parentId ?? null; parentId; ) {
|
|
2880
|
+
const parent = this.store.getScope(parentId);
|
|
2881
|
+
if (!parent) return false;
|
|
2882
|
+
if (parent.current_execution_id !== null) return true;
|
|
2883
|
+
parentId = parent.parent_instance_id;
|
|
2884
|
+
}
|
|
2885
|
+
return false;
|
|
2886
|
+
}
|
|
2887
|
+
rootFlowOf(instance) {
|
|
2888
|
+
const root = this.instances.get(instance.rootId);
|
|
2889
|
+
if (!root) {
|
|
2890
|
+
throw new Error(`Root instance ${instance.rootId} is not loaded`);
|
|
2891
|
+
}
|
|
2892
|
+
return root.flow;
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
2895
|
+
* Exits the active descendants of the source State (ADR 0095). `interrupt` first records,
|
|
2896
|
+
* durably and at once, the interruption of every descendant with running or unresolved work;
|
|
2897
|
+
* the remaining descendants run their own `onExit`, inner before outer, each checkpointed.
|
|
2898
|
+
*/
|
|
2899
|
+
async exitChildren(instance, execution) {
|
|
2900
|
+
const transition = this.requireTransition(
|
|
2901
|
+
instance.flow.definition,
|
|
2902
|
+
execution
|
|
2903
|
+
);
|
|
2904
|
+
if (transition.from.composition === null) return "done";
|
|
2905
|
+
if (transition.childWork === "interrupt") {
|
|
2906
|
+
this.interruptDescendants(instance, execution);
|
|
2907
|
+
}
|
|
2908
|
+
for (; ; ) {
|
|
2909
|
+
if (this.requireExecution(execution.execution_id).status !== "running") {
|
|
2910
|
+
return "waiting";
|
|
2911
|
+
}
|
|
2912
|
+
const next = this.activeDescendants(instance.id)[0];
|
|
2913
|
+
if (!next) break;
|
|
2914
|
+
const core = this.scopeCore(this.rootFlowOf(instance), next);
|
|
2915
|
+
if (next.status === "completed" || next.status === "ready") {
|
|
2916
|
+
const state = core.flow.definition.statesByName.get(next.state);
|
|
2917
|
+
if (next.status === "completed" || !state?.onExit) {
|
|
2918
|
+
this.store.transaction(() => {
|
|
2919
|
+
this.store.updateScope(next.instance_id, {
|
|
2920
|
+
active: 0,
|
|
2921
|
+
exit_reason: "exited"
|
|
2922
|
+
});
|
|
2923
|
+
this.store.updateInstance(next.instance_id, {
|
|
2924
|
+
status: "exited",
|
|
2925
|
+
current_execution_id: null
|
|
2926
|
+
});
|
|
2927
|
+
this.recordScopeEvent(core, "scope-exited", { state: next.state });
|
|
2928
|
+
});
|
|
2929
|
+
continue;
|
|
2930
|
+
}
|
|
2931
|
+
const exitId = mintExecutionId();
|
|
2932
|
+
this.store.transaction(() => {
|
|
2933
|
+
this.store.insertExecution({
|
|
2934
|
+
execution_id: exitId,
|
|
2935
|
+
instance_id: next.instance_id,
|
|
2936
|
+
kind: "exit",
|
|
2937
|
+
request_id: null,
|
|
2938
|
+
input_json: canonicalJson({ parent: execution.execution_id }),
|
|
2939
|
+
from_state: next.state,
|
|
2940
|
+
transition: null,
|
|
2941
|
+
to_state: null,
|
|
2942
|
+
step: "on-exit",
|
|
2943
|
+
status: "running",
|
|
2944
|
+
handler_revision: instance.flow.root.handlerRevision
|
|
2945
|
+
});
|
|
2946
|
+
this.store.updateInstance(next.instance_id, {
|
|
2947
|
+
status: "running",
|
|
2948
|
+
current_execution_id: exitId
|
|
2949
|
+
});
|
|
2950
|
+
});
|
|
2951
|
+
await this.track(core, exitId, () => this.advance(core, exitId));
|
|
2952
|
+
if (this.store.getScope(next.instance_id)?.active === 1) {
|
|
2953
|
+
return "waiting";
|
|
2954
|
+
}
|
|
2955
|
+
continue;
|
|
2956
|
+
}
|
|
2957
|
+
return "waiting";
|
|
2958
|
+
}
|
|
2959
|
+
this.store.transaction(() => {
|
|
2960
|
+
this.store.updateScope(instance.id, { children_activation_id: null });
|
|
2961
|
+
});
|
|
2962
|
+
return "done";
|
|
2963
|
+
}
|
|
2964
|
+
/** Durably fences every busy descendant of the source State, then aborts its signal. */
|
|
2965
|
+
interruptDescendants(instance, execution) {
|
|
2966
|
+
const victims = this.activeDescendants(instance.id).filter((row) => {
|
|
2967
|
+
if (row.current_execution_id === null) return false;
|
|
2968
|
+
const current = this.store.getExecution(row.current_execution_id);
|
|
2969
|
+
return current !== void 0 && current.kind !== "exit";
|
|
2970
|
+
});
|
|
2971
|
+
if (victims.length === 0) return;
|
|
2972
|
+
const cores = victims.map(
|
|
2973
|
+
(row) => this.scopeCore(this.rootFlowOf(instance), row)
|
|
2974
|
+
);
|
|
2975
|
+
this.store.transaction(() => {
|
|
2976
|
+
victims.forEach((row, index) => {
|
|
2977
|
+
const core = cores[index];
|
|
2978
|
+
const current = this.requireExecution(row.current_execution_id);
|
|
2979
|
+
this.store.updateExecution(current.execution_id, {
|
|
2980
|
+
status: "interrupted"
|
|
2981
|
+
});
|
|
2982
|
+
this.store.updateScope(row.instance_id, {
|
|
2983
|
+
active: 0,
|
|
2984
|
+
exit_reason: "interrupted"
|
|
2985
|
+
});
|
|
2986
|
+
const operation = this.store.findUnresolvedOperation(
|
|
2987
|
+
current.execution_id
|
|
2988
|
+
);
|
|
2989
|
+
if (!operation) {
|
|
2990
|
+
this.store.updateInstance(row.instance_id, {
|
|
2991
|
+
status: "exited",
|
|
2992
|
+
status_reason: null,
|
|
2993
|
+
current_execution_id: null
|
|
2994
|
+
});
|
|
2995
|
+
} else if (operation.status === "recovering" && operation.strategy !== "reconcile") {
|
|
2996
|
+
this.store.updateOperation(operation.operation_id, {
|
|
2997
|
+
status: "needs-attention",
|
|
2998
|
+
next_attempt_at: null
|
|
2999
|
+
});
|
|
3000
|
+
this.store.updateInstance(row.instance_id, {
|
|
3001
|
+
status: "needs-attention",
|
|
3002
|
+
status_reason: NO_REPLAY_REASON
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
this.record(
|
|
3006
|
+
row.instance_id,
|
|
3007
|
+
"scope-interrupted",
|
|
3008
|
+
{ transition: execution.transition },
|
|
3009
|
+
{ requestId: current.request_id }
|
|
3010
|
+
);
|
|
3011
|
+
this.recordScopeEvent(core, "scope-interrupted", {
|
|
3012
|
+
state: row.state,
|
|
3013
|
+
transition: execution.transition,
|
|
3014
|
+
requestId: current.request_id,
|
|
3015
|
+
retained: operation?.operation_id ?? null
|
|
3016
|
+
});
|
|
3017
|
+
});
|
|
3018
|
+
});
|
|
3019
|
+
for (const core of cores) {
|
|
3020
|
+
const operation = this.store.getScope(core.id)?.status;
|
|
3021
|
+
if (operation !== "recovering") this.clearTimer(core);
|
|
3022
|
+
core.abort.abort(
|
|
3023
|
+
new RuntimeClosedError("A parent Transition interrupted this scope")
|
|
3024
|
+
);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Creates the child scopes of the composite State this execution entered, together with the
|
|
3029
|
+
* execution's completion (ADR 0096). Idempotent per activation. Returns the created scopes.
|
|
3030
|
+
*/
|
|
3031
|
+
activateChildren(instance, execution) {
|
|
3032
|
+
const definition = instance.flow.definition;
|
|
3033
|
+
const target2 = definition.statesByName.get(execution.to_state ?? "");
|
|
3034
|
+
const composition = target2?.composition ?? null;
|
|
3035
|
+
const row = this.store.getScope(instance.id);
|
|
3036
|
+
if (!composition || !row) {
|
|
3037
|
+
this.finish(instance, execution, "applied");
|
|
3038
|
+
return [];
|
|
3039
|
+
}
|
|
3040
|
+
if (row.children_activation_id === execution.execution_id) {
|
|
3041
|
+
this.finish(instance, execution, "applied");
|
|
3042
|
+
return this.store.listChildScopes(instance.id).filter((child) => child.activation_id === execution.execution_id).map((child) => this.scopeCore(this.rootFlowOf(instance), child));
|
|
3043
|
+
}
|
|
3044
|
+
const inputs = (() => {
|
|
3045
|
+
const operation = this.store.findOperation(
|
|
3046
|
+
execution.execution_id,
|
|
3047
|
+
"children"
|
|
3048
|
+
);
|
|
3049
|
+
return operation?.status === "completed" ? parseJson(operation.result_json) : {};
|
|
3050
|
+
})();
|
|
3051
|
+
const rootId = instance.rootId;
|
|
3052
|
+
const basePath = instance.scope === null ? "" : `${instance.scope.path}${instance.scope.memberKey === null ? "" : `[${instance.scope.memberKey}]`}/`;
|
|
3053
|
+
const created = [];
|
|
3054
|
+
this.store.transaction(() => {
|
|
3055
|
+
let position = 0;
|
|
3056
|
+
for (const region of composition.regions) {
|
|
3057
|
+
const members = region.kind === "list" ? inputs[region.name] ?? [] : [
|
|
3058
|
+
{
|
|
3059
|
+
key: null,
|
|
3060
|
+
input: region.kind === "flow" ? inputs[region.name] ?? null : null
|
|
3061
|
+
}
|
|
3062
|
+
];
|
|
3063
|
+
for (const member of members) {
|
|
3064
|
+
const scopeId = `scp_${mintExecutionId().slice(4)}`;
|
|
3065
|
+
const createId = mintExecutionId();
|
|
3066
|
+
const graph = region.graph;
|
|
3067
|
+
const scopeRow = {
|
|
3068
|
+
instance_id: scopeId,
|
|
3069
|
+
chart_name: graph.flowName,
|
|
3070
|
+
definition_id: instance.flow.root.definitionId,
|
|
3071
|
+
state: graph.initialState.name,
|
|
3072
|
+
status: "running",
|
|
3073
|
+
status_reason: null,
|
|
3074
|
+
current_execution_id: createId,
|
|
3075
|
+
root_instance_id: rootId,
|
|
3076
|
+
parent_instance_id: instance.id,
|
|
3077
|
+
scope_path: `${basePath}${region.segment}`,
|
|
3078
|
+
segment: region.segment,
|
|
3079
|
+
graph: graph.key,
|
|
3080
|
+
activation_id: scopeId,
|
|
3081
|
+
member_key: member.key,
|
|
3082
|
+
input_json: canonicalJson(member.input),
|
|
3083
|
+
position: position++
|
|
3084
|
+
};
|
|
3085
|
+
this.store.insertScope(scopeRow);
|
|
3086
|
+
this.store.insertExecution({
|
|
3087
|
+
execution_id: createId,
|
|
3088
|
+
instance_id: scopeId,
|
|
3089
|
+
kind: "create",
|
|
3090
|
+
request_id: null,
|
|
3091
|
+
input_json: canonicalJson({ startState: null }),
|
|
3092
|
+
from_state: null,
|
|
3093
|
+
transition: null,
|
|
3094
|
+
to_state: graph.initialState.name,
|
|
3095
|
+
// Nested activations always start at their default (ADR 0096).
|
|
3096
|
+
step: "commit",
|
|
3097
|
+
status: "running",
|
|
3098
|
+
handler_revision: instance.flow.root.handlerRevision
|
|
3099
|
+
});
|
|
3100
|
+
created.push(this.store.getScope(scopeId));
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
this.store.updateScope(instance.id, {
|
|
3104
|
+
children_activation_id: execution.execution_id
|
|
3105
|
+
});
|
|
3106
|
+
this.record(
|
|
3107
|
+
rootId,
|
|
3108
|
+
"scope-activated",
|
|
3109
|
+
{
|
|
3110
|
+
state: target2.name,
|
|
3111
|
+
parentPath: instance.scope?.path ?? null,
|
|
3112
|
+
scopes: created.map((child) => ({
|
|
3113
|
+
path: child.scope_path ?? "",
|
|
3114
|
+
activationId: child.activation_id ?? "",
|
|
3115
|
+
memberKey: child.member_key
|
|
3116
|
+
}))
|
|
3117
|
+
},
|
|
3118
|
+
{ requestId: execution.request_id }
|
|
3119
|
+
);
|
|
3120
|
+
this.finishInTransaction(instance, execution, "applied");
|
|
3121
|
+
});
|
|
3122
|
+
this.notify(instance);
|
|
3123
|
+
return created.map(
|
|
3124
|
+
(child) => this.scopeCore(this.rootFlowOf(instance), child)
|
|
3125
|
+
);
|
|
3126
|
+
}
|
|
3127
|
+
/** Runs each new child's entry in authored order; each checkpoints independently. */
|
|
3128
|
+
async enterChildren(instance, children) {
|
|
3129
|
+
for (const child of children) {
|
|
3130
|
+
if (this.closing) return;
|
|
3131
|
+
const row = this.store.getScope(child.id);
|
|
3132
|
+
if (!row || row.current_execution_id === null || row.active !== 1) {
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
const executionId = row.current_execution_id;
|
|
3136
|
+
if (child.activeExecutionId !== null) continue;
|
|
3137
|
+
await this.track(
|
|
3138
|
+
child,
|
|
3139
|
+
executionId,
|
|
3140
|
+
() => this.advance(child, executionId)
|
|
3141
|
+
);
|
|
3142
|
+
}
|
|
3143
|
+
this.scheduleIdle(instance);
|
|
3144
|
+
}
|
|
3145
|
+
/** After a scope settles: route completion and resume an ancestor waiting to exit it. */
|
|
3146
|
+
onIdle(instance) {
|
|
3147
|
+
this.checkCompletion(instance);
|
|
3148
|
+
for (let parentId = instance.scope?.parentId ?? null; parentId; ) {
|
|
3149
|
+
const parent = this.instances.get(parentId);
|
|
3150
|
+
if (!parent) return;
|
|
3151
|
+
this.checkCompletion(parent);
|
|
3152
|
+
this.resumeExit(parent);
|
|
3153
|
+
parentId = parent.scope?.parentId ?? null;
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
/** Resumes an ancestor's exit that was waiting for a descendant's onExit. */
|
|
3157
|
+
resumeExit(instance) {
|
|
3158
|
+
if (instance.activeExecutionId !== null || this.closing) return;
|
|
3159
|
+
const row = this.store.getScope(instance.id);
|
|
3160
|
+
if (!row || row.current_execution_id === null || row.status !== "running") {
|
|
3161
|
+
return;
|
|
3162
|
+
}
|
|
3163
|
+
const execution = this.requireExecution(row.current_execution_id);
|
|
3164
|
+
if (execution.step !== "exit-children" || execution.status !== "running") {
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
void this.track(
|
|
3168
|
+
instance,
|
|
3169
|
+
execution.execution_id,
|
|
3170
|
+
() => this.advance(instance, execution.execution_id)
|
|
3171
|
+
).catch((error) => {
|
|
3172
|
+
this.markStalled(instance, error);
|
|
3173
|
+
this.reportRecoveryError(error);
|
|
3174
|
+
});
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Fires the composite State's `trigger="done"` Transition once every child of the current
|
|
3178
|
+
* activation has completed (ADR 0096). The request ID is derived from the activation, so the
|
|
3179
|
+
* completion fires at most once, including across restarts.
|
|
3180
|
+
*/
|
|
3181
|
+
checkCompletion(instance) {
|
|
3182
|
+
if (instance.activeExecutionId !== null || this.closing) return;
|
|
3183
|
+
const row = this.store.getScope(instance.id);
|
|
3184
|
+
if (!row || row.status !== "ready" || row.active === 0) return;
|
|
3185
|
+
if (this.ancestorBusy(instance)) return;
|
|
3186
|
+
const activation = row.children_activation_id;
|
|
3187
|
+
if (!activation) return;
|
|
3188
|
+
const state = instance.flow.definition.statesByName.get(row.state);
|
|
3189
|
+
const composition = state?.composition;
|
|
3190
|
+
const done = composition?.done;
|
|
3191
|
+
if (!composition || !done) return;
|
|
3192
|
+
const current = this.store.listChildScopes(instance.id).filter((child) => child.active === 1);
|
|
3193
|
+
if (current.some((child) => child.status !== "completed")) return;
|
|
3194
|
+
const requestId2 = `done:${activation}`;
|
|
3195
|
+
if (this.store.findExecutionByRequest(instance.id, requestId2)) return;
|
|
3196
|
+
const regions = {};
|
|
3197
|
+
for (const region of composition.regions) {
|
|
3198
|
+
const inRegion = current.filter(
|
|
3199
|
+
(child) => child.segment === region.segment
|
|
3200
|
+
);
|
|
3201
|
+
regions[region.name] = region.kind === "list" ? {
|
|
3202
|
+
members: inRegion.map((child) => ({
|
|
3203
|
+
key: child.member_key ?? "",
|
|
3204
|
+
final: child.state
|
|
3205
|
+
}))
|
|
3206
|
+
} : { final: inRegion[0]?.state ?? null };
|
|
3207
|
+
}
|
|
3208
|
+
const payload = { regions };
|
|
3209
|
+
const executionId = mintExecutionId();
|
|
3210
|
+
this.store.transaction(() => {
|
|
3211
|
+
this.store.insertExecution({
|
|
3212
|
+
execution_id: executionId,
|
|
3213
|
+
instance_id: instance.id,
|
|
3214
|
+
kind: "dispatch",
|
|
3215
|
+
request_id: requestId2,
|
|
3216
|
+
input_json: canonicalJson({
|
|
3217
|
+
fromState: row.state,
|
|
3218
|
+
transition: done.name,
|
|
3219
|
+
payload
|
|
3220
|
+
}),
|
|
3221
|
+
from_state: row.state,
|
|
3222
|
+
transition: done.name,
|
|
3223
|
+
to_state: null,
|
|
3224
|
+
step: "guard",
|
|
3225
|
+
status: "running",
|
|
3226
|
+
handler_revision: instance.flow.root.handlerRevision
|
|
3227
|
+
});
|
|
3228
|
+
this.store.updateInstance(instance.id, {
|
|
3229
|
+
status: "running",
|
|
3230
|
+
current_execution_id: executionId
|
|
3231
|
+
});
|
|
3232
|
+
this.record(
|
|
3233
|
+
instance.id,
|
|
3234
|
+
"execution-started",
|
|
3235
|
+
{
|
|
3236
|
+
transition: done.name,
|
|
3237
|
+
from: row.state,
|
|
3238
|
+
destinations: done.destinations.map((target2) => target2.name),
|
|
3239
|
+
payload,
|
|
3240
|
+
trigger: "done"
|
|
3241
|
+
},
|
|
3242
|
+
{ requestId: requestId2 }
|
|
3243
|
+
);
|
|
3244
|
+
});
|
|
3245
|
+
this.notify(instance);
|
|
3246
|
+
void this.track(
|
|
3247
|
+
instance,
|
|
3248
|
+
executionId,
|
|
3249
|
+
() => this.advance(instance, executionId)
|
|
3250
|
+
).catch((error) => {
|
|
3251
|
+
this.markStalled(instance, error);
|
|
3252
|
+
this.reportRecoveryError(error);
|
|
3253
|
+
});
|
|
3254
|
+
}
|
|
3255
|
+
// --- lookups -------------------------------------------------------------
|
|
3256
|
+
requireExecution(executionId) {
|
|
3257
|
+
const execution = this.store.getExecution(executionId);
|
|
3258
|
+
if (!execution) {
|
|
3259
|
+
throw new Error(`Execution ${executionId} is missing from storage`);
|
|
3260
|
+
}
|
|
3261
|
+
return execution;
|
|
3262
|
+
}
|
|
3263
|
+
requireOperation(operationId) {
|
|
3264
|
+
const operation = this.store.getOperation(operationId);
|
|
3265
|
+
if (!operation) {
|
|
3266
|
+
throw new Error(`Operation ${operationId} is missing from storage`);
|
|
3267
|
+
}
|
|
3268
|
+
return operation;
|
|
3269
|
+
}
|
|
3270
|
+
};
|
|
3271
|
+
function isThenable(value) {
|
|
3272
|
+
return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
|
|
3273
|
+
}
|
|
3274
|
+
function isRecord2(value) {
|
|
3275
|
+
return typeof value === "object" && value !== null;
|
|
3276
|
+
}
|
|
3277
|
+
var NO_REPLAY_REASON = "Interrupted work is never replayed after its activation exited; establish its outcome with Confirm it completed, or Terminate";
|
|
3278
|
+
function exhaustedReason(operation) {
|
|
3279
|
+
const last = operation.last_error ? `: ${operation.last_error}` : "";
|
|
3280
|
+
return `Automatic recovery exhausted after ${operation.attempts} attempts${last}`;
|
|
3281
|
+
}
|
|
3282
|
+
function nextStep(step) {
|
|
3283
|
+
switch (step) {
|
|
3284
|
+
case "on-exit":
|
|
3285
|
+
return "on-transition";
|
|
3286
|
+
case "on-transition":
|
|
3287
|
+
return "commit";
|
|
3288
|
+
case "on-enter":
|
|
3289
|
+
return "children";
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
function callbackTarget(invocation, kind) {
|
|
3293
|
+
const context = invocation.context;
|
|
3294
|
+
switch (kind) {
|
|
3295
|
+
case "resolve-start-state":
|
|
3296
|
+
return null;
|
|
3297
|
+
case "on-enter":
|
|
3298
|
+
case "on-exit":
|
|
3299
|
+
case "children":
|
|
3300
|
+
return context.stateName;
|
|
3301
|
+
case "guard":
|
|
3302
|
+
case "on-transition":
|
|
3303
|
+
return context.transitionName;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
function callbackRef(operation) {
|
|
3307
|
+
const target2 = operation.callback_target ?? "";
|
|
3308
|
+
switch (operation.callback_kind) {
|
|
3309
|
+
case "resolve-start-state":
|
|
3310
|
+
return { kind: "resolve-start-state" };
|
|
3311
|
+
case "on-enter":
|
|
3312
|
+
case "on-exit":
|
|
3313
|
+
case "children":
|
|
3314
|
+
return { kind: operation.callback_kind, state: target2 };
|
|
3315
|
+
case "guard":
|
|
3316
|
+
case "on-transition":
|
|
3317
|
+
return { kind: operation.callback_kind, transition: target2 };
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
// src/durable/resolve-input.ts
|
|
3322
|
+
import { z as z3 } from "zod";
|
|
3323
|
+
var target = {
|
|
3324
|
+
operationId: z3.string().min(1).transform((value) => value),
|
|
3325
|
+
expectedAttempts: z3.number().int().nonnegative(),
|
|
3326
|
+
reason: z3.string().refine((value) => value.trim().length > 0, "A reason is required")
|
|
3327
|
+
};
|
|
3328
|
+
var confirmation = z3.discriminatedUnion("callback", [
|
|
3329
|
+
z3.strictObject({
|
|
3330
|
+
...target,
|
|
3331
|
+
action: z3.literal("confirm-completed"),
|
|
3332
|
+
callback: z3.literal("guard"),
|
|
3333
|
+
result: z3.boolean()
|
|
3334
|
+
}),
|
|
3335
|
+
z3.strictObject({
|
|
3336
|
+
...target,
|
|
3337
|
+
action: z3.literal("confirm-completed"),
|
|
3338
|
+
callback: z3.literal("resolve-start-state"),
|
|
3339
|
+
result: z3.string()
|
|
3340
|
+
}),
|
|
3341
|
+
// A branching onTransition is confirmed with its selected destination (ADR 0087).
|
|
3342
|
+
z3.strictObject({
|
|
3343
|
+
...target,
|
|
3344
|
+
action: z3.literal("confirm-completed"),
|
|
3345
|
+
callback: z3.literal("on-transition"),
|
|
3346
|
+
result: z3.string().nullable().optional()
|
|
3347
|
+
}).transform(
|
|
3348
|
+
({ result, ...input }) => typeof result === "string" ? { ...input, result } : input
|
|
3349
|
+
),
|
|
3350
|
+
// A composite State's children callback is confirmed with its inputs and members (ADR 0096).
|
|
3351
|
+
z3.strictObject({
|
|
3352
|
+
...target,
|
|
3353
|
+
action: z3.literal("confirm-completed"),
|
|
3354
|
+
callback: z3.literal("children"),
|
|
3355
|
+
result: z3.json()
|
|
3356
|
+
}),
|
|
3357
|
+
z3.strictObject({
|
|
3358
|
+
...target,
|
|
3359
|
+
action: z3.literal("confirm-completed"),
|
|
3360
|
+
callback: z3.enum(["on-enter", "on-exit"]),
|
|
3361
|
+
result: z3.null().optional()
|
|
3362
|
+
}).transform(({ result, ...input }) => {
|
|
3363
|
+
void result;
|
|
3364
|
+
return input;
|
|
3365
|
+
})
|
|
3366
|
+
]);
|
|
3367
|
+
var resolveInputSchema = z3.discriminatedUnion("action", [
|
|
3368
|
+
z3.strictObject({ ...target, action: z3.literal("retry") }),
|
|
3369
|
+
z3.strictObject({ ...target, action: z3.literal("terminate") }),
|
|
3370
|
+
confirmation
|
|
3371
|
+
]);
|
|
3372
|
+
function parseResolveInput(value) {
|
|
3373
|
+
const result = resolveInputSchema.safeParse(value);
|
|
3374
|
+
if (!result.success) {
|
|
3375
|
+
throw new InputError(`Invalid resolve input: ${result.error.message}`);
|
|
3376
|
+
}
|
|
3377
|
+
return result.data;
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
// src/durable/runtime.ts
|
|
3381
|
+
var runtimeCores = /* @__PURE__ */ new WeakMap();
|
|
3382
|
+
function createUnmanagedRuntime(options) {
|
|
3383
|
+
const store = new SqliteStore(options.databasePath);
|
|
3384
|
+
const core = new RuntimeCore(
|
|
3385
|
+
store,
|
|
3386
|
+
options.onObserverError ?? ((error) => console.error("State Flow observer failed", error)),
|
|
3387
|
+
options.onRecoveryError ?? ((error) => console.error("State Flow background recovery failed", error))
|
|
3388
|
+
);
|
|
3389
|
+
const flowsByName = /* @__PURE__ */ new Map();
|
|
3390
|
+
core.dependenciesOf = (flowName) => flowsByName.get(flowName)?.dependencies;
|
|
3391
|
+
function registerFlow(compiled, dependencies) {
|
|
3392
|
+
core.assertOpen();
|
|
3393
|
+
const definition = loadFlowDefinition(compiled);
|
|
3394
|
+
if (flowsByName.has(definition.flowName))
|
|
3395
|
+
throw new InputError(
|
|
3396
|
+
`Flow "${definition.flowName}" is already registered with this runtime`
|
|
3397
|
+
);
|
|
3398
|
+
for (const [name, placed] of definition.placed) {
|
|
3399
|
+
const registered = flowsByName.get(name);
|
|
3400
|
+
if (!registered) {
|
|
3401
|
+
throw new InputError(
|
|
3402
|
+
`Flow "${definition.flowName}" places "${name}": register "${name}" first so its dependencies are available`
|
|
3403
|
+
);
|
|
3404
|
+
}
|
|
3405
|
+
if (registered.root.definitionId !== placed.definitionId) {
|
|
3406
|
+
throw new InputError(
|
|
3407
|
+
`Flow "${definition.flowName}" places "${name}" with definition ${placed.definitionId}, but the registered "${name}" has ${registered.root.definitionId}; register modules from the same build`
|
|
3408
|
+
);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
const flow = { definition, root: definition, dependencies };
|
|
3412
|
+
const api = createFlowApi(core, flow);
|
|
3413
|
+
flowsByName.set(definition.flowName, flow);
|
|
3414
|
+
return api;
|
|
3415
|
+
}
|
|
3416
|
+
const runtime = {
|
|
3417
|
+
async createFlow(compiled, dependencies) {
|
|
3418
|
+
return registerFlow(
|
|
3419
|
+
compiled,
|
|
3420
|
+
dependencies
|
|
3421
|
+
);
|
|
3422
|
+
},
|
|
3423
|
+
close: (options2) => core.close(options2),
|
|
3424
|
+
handover: (options2) => core.handover(options2),
|
|
3425
|
+
inspector: {
|
|
3426
|
+
listInstances: (query) => core.listInstances(query),
|
|
3427
|
+
getSnapshot: (id) => {
|
|
3428
|
+
const valid = instanceId(id);
|
|
3429
|
+
return core.store.getInstance(valid) ? core.snapshotOf(valid) : null;
|
|
3430
|
+
},
|
|
3431
|
+
history: (id, query) => core.history(instanceId(id), query),
|
|
3432
|
+
subscribe(listener) {
|
|
3433
|
+
const wrapped = (snapshot) => listener(snapshot);
|
|
3434
|
+
core.runtimeListeners.add(wrapped);
|
|
3435
|
+
return () => {
|
|
3436
|
+
core.runtimeListeners.delete(wrapped);
|
|
3437
|
+
};
|
|
3438
|
+
},
|
|
3439
|
+
lateOutcomes(query = {}) {
|
|
3440
|
+
core.assertStoreOpen();
|
|
3441
|
+
return Object.freeze(
|
|
3442
|
+
core.pendingLateOutcomes(
|
|
3443
|
+
query.instanceId === void 0 ? null : instanceId(query.instanceId)
|
|
3444
|
+
)
|
|
3445
|
+
);
|
|
3446
|
+
},
|
|
3447
|
+
subscribeLateOutcomes(listener) {
|
|
3448
|
+
const wrapped = (outcome) => listener(outcome);
|
|
3449
|
+
core.lateListeners.add(wrapped);
|
|
3450
|
+
return () => {
|
|
3451
|
+
core.lateListeners.delete(wrapped);
|
|
3452
|
+
};
|
|
3453
|
+
}
|
|
3454
|
+
},
|
|
3455
|
+
controls: {
|
|
3456
|
+
acknowledgeLateOutcome(sequence) {
|
|
3457
|
+
core.assertStoreOpen();
|
|
3458
|
+
if (!Number.isInteger(sequence)) {
|
|
3459
|
+
throw new InputError("Late outcome sequence must be an integer");
|
|
3460
|
+
}
|
|
3461
|
+
return core.store.acknowledgeLateOutcome(sequence);
|
|
3462
|
+
},
|
|
3463
|
+
async resolve(id, untrustedInput) {
|
|
3464
|
+
core.assertOpen();
|
|
3465
|
+
const valid = instanceId(id);
|
|
3466
|
+
const input = parseResolveInput(untrustedInput);
|
|
3467
|
+
const row = core.store.getInstance(valid);
|
|
3468
|
+
if (!row) {
|
|
3469
|
+
return {
|
|
3470
|
+
outcome: "rejected",
|
|
3471
|
+
reason: "unavailable",
|
|
3472
|
+
message: `Instance ${valid} does not exist`
|
|
3473
|
+
};
|
|
3474
|
+
}
|
|
3475
|
+
const flow = flowsByName.get(row.chart_name);
|
|
3476
|
+
const loaded = flow ? loadInstance(core, flow, valid) : void 0;
|
|
3477
|
+
if (loaded?.kind === "opened") {
|
|
3478
|
+
return resolve(core, loaded.instance, input);
|
|
3479
|
+
}
|
|
3480
|
+
if (input.action === "terminate") {
|
|
3481
|
+
return terminateUnloadable(core, valid, input);
|
|
3482
|
+
}
|
|
3483
|
+
return {
|
|
3484
|
+
outcome: "rejected",
|
|
3485
|
+
reason: "unavailable",
|
|
3486
|
+
message: flow ? `Instance ${valid} cannot be loaded by the registered flow (${loaded?.kind}); only terminate is available` : `No flow named "${row.chart_name}" is registered; only terminate is available`
|
|
3487
|
+
};
|
|
3488
|
+
},
|
|
3489
|
+
restartRecovery(id) {
|
|
3490
|
+
const instance = core.instances.get(instanceId(id));
|
|
3491
|
+
return instance ? core.restartRecovery(instance) : false;
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
};
|
|
3495
|
+
runtimeCores.set(runtime, core);
|
|
3496
|
+
return runtime;
|
|
3497
|
+
}
|
|
3498
|
+
function createFlowApi(core, flow) {
|
|
3499
|
+
const definition = flow.root;
|
|
3500
|
+
const instanceApis = /* @__PURE__ */ new WeakMap();
|
|
3501
|
+
const apiFor = (instance) => {
|
|
3502
|
+
let api = instanceApis.get(instance);
|
|
3503
|
+
if (!api) {
|
|
3504
|
+
api = createInstanceApi(core, instance);
|
|
3505
|
+
instanceApis.set(instance, api);
|
|
3506
|
+
}
|
|
3507
|
+
return api;
|
|
3508
|
+
};
|
|
3509
|
+
async function createInstance(id) {
|
|
3510
|
+
if (core.instances.has(id) || core.store.getInstance(id)) {
|
|
3511
|
+
return { kind: "already-exists", id };
|
|
3512
|
+
}
|
|
3513
|
+
const executionId = mintExecutionId();
|
|
3514
|
+
core.store.transaction(() => {
|
|
3515
|
+
core.store.insertInstance({
|
|
3516
|
+
instance_id: id,
|
|
3517
|
+
chart_name: definition.flowName,
|
|
3518
|
+
definition_id: definition.definitionId,
|
|
3519
|
+
state: definition.initialState.name,
|
|
3520
|
+
status: "running",
|
|
3521
|
+
status_reason: null,
|
|
3522
|
+
current_execution_id: executionId
|
|
3523
|
+
});
|
|
3524
|
+
core.store.insertExecution({
|
|
3525
|
+
execution_id: executionId,
|
|
3526
|
+
instance_id: id,
|
|
3527
|
+
kind: "create",
|
|
3528
|
+
request_id: null,
|
|
3529
|
+
// `resolveStartState` (or the initial state) chooses where the instance starts. The field
|
|
3530
|
+
// stays null; executions persisted by earlier versions may still carry a start state.
|
|
3531
|
+
input_json: canonicalJson({ startState: null }),
|
|
3532
|
+
from_state: null,
|
|
3533
|
+
transition: null,
|
|
3534
|
+
to_state: null,
|
|
3535
|
+
step: "resolve-start-state",
|
|
3536
|
+
status: "running",
|
|
3537
|
+
handler_revision: definition.handlerRevision
|
|
3538
|
+
});
|
|
3539
|
+
core.record(id, "instance-created", {
|
|
3540
|
+
flowName: definition.flowName,
|
|
3541
|
+
definitionId: definition.definitionId
|
|
3542
|
+
});
|
|
3543
|
+
});
|
|
3544
|
+
const instance = new InstanceCore(id, flow);
|
|
3545
|
+
core.instances.set(id, instance);
|
|
3546
|
+
await core.track(
|
|
3547
|
+
instance,
|
|
3548
|
+
executionId,
|
|
3549
|
+
() => core.advance(instance, executionId)
|
|
3550
|
+
);
|
|
3551
|
+
return { kind: "created", instance, executionId };
|
|
3552
|
+
}
|
|
3553
|
+
return {
|
|
3554
|
+
flowName: definition.flowName,
|
|
3555
|
+
definitionId: definition.definitionId,
|
|
3556
|
+
async ensureInstance(input) {
|
|
3557
|
+
core.assertOpen();
|
|
3558
|
+
const id = instanceId(input.instanceId);
|
|
3559
|
+
let loaded = loadInstance(core, flow, id);
|
|
3560
|
+
if (loaded.kind === "not-found") {
|
|
3561
|
+
const created = await createInstance(id);
|
|
3562
|
+
loaded = created.kind === "created" ? { kind: "opened", instance: created.instance } : loadInstance(core, flow, id);
|
|
3563
|
+
}
|
|
3564
|
+
if (loaded.kind === "mismatch") {
|
|
3565
|
+
throw new DefinitionMismatchError(
|
|
3566
|
+
loaded.context
|
|
3567
|
+
);
|
|
3568
|
+
}
|
|
3569
|
+
if (loaded.kind === "not-found") {
|
|
3570
|
+
throw new InputError(`Instance ${id} disappeared during ensure`);
|
|
3571
|
+
}
|
|
3572
|
+
return apiFor(loaded.instance);
|
|
3573
|
+
}
|
|
3574
|
+
};
|
|
3575
|
+
}
|
|
3576
|
+
var UNFINISHED = /* @__PURE__ */ new Set([
|
|
3577
|
+
"running",
|
|
3578
|
+
"recovering",
|
|
3579
|
+
"needs-attention"
|
|
3580
|
+
]);
|
|
3581
|
+
function loadInstance(core, flow, id) {
|
|
3582
|
+
const definition = flow.root;
|
|
3583
|
+
const row = core.store.getScope(id);
|
|
3584
|
+
if (!row) {
|
|
3585
|
+
return { kind: "not-found" };
|
|
3586
|
+
}
|
|
3587
|
+
const cached = core.instances.get(id);
|
|
3588
|
+
if (row.root_instance_id != null || row.chart_name !== definition.flowName || row.definition_id !== definition.definitionId || cached !== void 0 && cached.flow !== flow) {
|
|
3589
|
+
return {
|
|
3590
|
+
kind: "mismatch",
|
|
3591
|
+
context: {
|
|
3592
|
+
reason: "definition",
|
|
3593
|
+
instanceId: id,
|
|
3594
|
+
message: row.root_instance_id != null ? `Instance ${id} is a nested scope of ${row.root_instance_id}, not a root instance` : `Instance ${id} uses flow "${row.chart_name}" with definition ${row.definition_id}; this flow uses "${definition.flowName}" with definition ${definition.definitionId}`,
|
|
3595
|
+
storedFlowName: row.chart_name,
|
|
3596
|
+
storedDefinitionId: row.definition_id,
|
|
3597
|
+
currentDefinitionId: definition.definitionId
|
|
3598
|
+
}
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
3601
|
+
if (cached === void 0) {
|
|
3602
|
+
const stale = core.store.unfinishedHandlerRevisions(id).find((revision) => revision !== definition.handlerRevision);
|
|
3603
|
+
if (stale !== void 0) {
|
|
3604
|
+
return {
|
|
3605
|
+
kind: "mismatch",
|
|
3606
|
+
context: {
|
|
3607
|
+
reason: "handler-revision",
|
|
3608
|
+
instanceId: id,
|
|
3609
|
+
message: `Instance ${id} has unresolved work from handler revision ${stale}; this build uses ${definition.handlerRevision}`,
|
|
3610
|
+
storedHandlerRevision: stale,
|
|
3611
|
+
currentHandlerRevision: definition.handlerRevision
|
|
3612
|
+
}
|
|
3613
|
+
};
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
if (cached) {
|
|
3617
|
+
if (cached.stalled) {
|
|
3618
|
+
core.startRecovery(cached);
|
|
3619
|
+
}
|
|
3620
|
+
return { kind: "opened", instance: cached };
|
|
3621
|
+
}
|
|
3622
|
+
const instance = new InstanceCore(id, flow);
|
|
3623
|
+
core.instances.set(id, instance);
|
|
3624
|
+
core.startRecovery(instance);
|
|
3625
|
+
if (definition.composite) {
|
|
3626
|
+
const scopes = core.store.listScopes(id).filter((scope) => scope.active === 1 || UNFINISHED.has(scope.status)).map((scope) => core.scopeCore(flow, scope));
|
|
3627
|
+
for (const scope of scopes) {
|
|
3628
|
+
core.startRecovery(scope);
|
|
3629
|
+
}
|
|
3630
|
+
for (const scope of [instance, ...scopes]) {
|
|
3631
|
+
core.scheduleIdle(scope);
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
return { kind: "opened", instance };
|
|
3635
|
+
}
|
|
3636
|
+
function activeChildScope(core, parentId, segment, memberKey) {
|
|
3637
|
+
return core.store.listChildScopes(parentId).find(
|
|
3638
|
+
(row) => row.active === 1 && row.segment === segment && (row.member_key ?? null) === memberKey
|
|
3639
|
+
);
|
|
3640
|
+
}
|
|
3641
|
+
function regionAt(graph, segment) {
|
|
3642
|
+
for (const state of graph.statesByName.values()) {
|
|
3643
|
+
for (const region of state.composition?.regions ?? []) {
|
|
3644
|
+
if (region.segment === segment) return region;
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
return void 0;
|
|
3648
|
+
}
|
|
3649
|
+
function scopeNavigation(core, locator) {
|
|
3650
|
+
return (segment, memberKey) => {
|
|
3651
|
+
const region = regionAt(locator.graph, segment);
|
|
3652
|
+
if (!region) {
|
|
3653
|
+
throw new InputError(
|
|
3654
|
+
`No nested scope ${JSON.stringify(segment)} in ${locator.path === "" ? `flow "${locator.graph.flowName}"` : `scope "${locator.path}"`}`
|
|
3655
|
+
);
|
|
3656
|
+
}
|
|
3657
|
+
if (region.kind === "list" !== (memberKey !== void 0)) {
|
|
3658
|
+
throw new InputError(
|
|
3659
|
+
region.kind === "list" ? `Scope ${JSON.stringify(segment)} is a FlowList: pass a member key` : `Scope ${JSON.stringify(segment)} is not a FlowList: it takes no member key`
|
|
3660
|
+
);
|
|
3661
|
+
}
|
|
3662
|
+
const key = memberKey ?? null;
|
|
3663
|
+
if (key !== null && (typeof key !== "string" || key.length === 0)) {
|
|
3664
|
+
throw new InputError("FlowList member keys are non-empty strings");
|
|
3665
|
+
}
|
|
3666
|
+
return createScopeHandle(core, {
|
|
3667
|
+
root: locator.root,
|
|
3668
|
+
parentId: locator.parentId,
|
|
3669
|
+
graph: region.graph,
|
|
3670
|
+
path: locator.path === "" ? segment : `${locator.path}/${segment}`,
|
|
3671
|
+
segment,
|
|
3672
|
+
memberKey: key
|
|
3673
|
+
});
|
|
3674
|
+
};
|
|
3675
|
+
}
|
|
3676
|
+
function createScopeHandle(core, target2) {
|
|
3677
|
+
const resolveRow = () => {
|
|
3678
|
+
const parentId = target2.parentId();
|
|
3679
|
+
return parentId === null ? void 0 : activeChildScope(core, parentId, target2.segment, target2.memberKey);
|
|
3680
|
+
};
|
|
3681
|
+
const resolveCore = () => {
|
|
3682
|
+
const row = resolveRow();
|
|
3683
|
+
return row ? core.scopeCore(target2.root.flow, row) : void 0;
|
|
3684
|
+
};
|
|
3685
|
+
return {
|
|
3686
|
+
path: target2.path,
|
|
3687
|
+
memberKey: target2.memberKey,
|
|
3688
|
+
scope: scopeNavigation(core, {
|
|
3689
|
+
root: target2.root,
|
|
3690
|
+
parentId: () => resolveRow()?.instance_id ?? null,
|
|
3691
|
+
graph: target2.graph,
|
|
3692
|
+
path: target2.memberKey === null ? target2.path : `${target2.path}[${target2.memberKey}]`
|
|
3693
|
+
}),
|
|
3694
|
+
getSnapshot: () => {
|
|
3695
|
+
core.assertStoreOpen();
|
|
3696
|
+
const row = resolveRow();
|
|
3697
|
+
return row ? core.snapshotOf(row.instance_id) : null;
|
|
3698
|
+
},
|
|
3699
|
+
history: (query) => {
|
|
3700
|
+
const row = resolveRow();
|
|
3701
|
+
return row ? core.history(row.instance_id, query) : Object.freeze({ events: Object.freeze([]), nextAfterSequence: null });
|
|
3702
|
+
},
|
|
3703
|
+
when(from) {
|
|
3704
|
+
const hasOutgoing = [...target2.graph.transitionsByName.values()].some(
|
|
3705
|
+
(transition) => transition.from.name === from && transition.trigger === "dispatch"
|
|
3706
|
+
);
|
|
3707
|
+
if (!hasOutgoing) {
|
|
3708
|
+
throw new InputError(
|
|
3709
|
+
`State ${JSON.stringify(from)} has no dispatchable transitions in scope "${target2.path}"`
|
|
3710
|
+
);
|
|
3711
|
+
}
|
|
3712
|
+
return {
|
|
3713
|
+
handle(handlers) {
|
|
3714
|
+
assertHandlers(handlers, SCOPED_DISPATCH_OUTCOMES);
|
|
3715
|
+
return {
|
|
3716
|
+
dispatch: async (input) => {
|
|
3717
|
+
core.assertOpen();
|
|
3718
|
+
const requestId2 = requestId(input.requestId);
|
|
3719
|
+
const recorded = core.store.findScopedExecutionByRequest(
|
|
3720
|
+
target2.root.id,
|
|
3721
|
+
target2.path,
|
|
3722
|
+
target2.memberKey,
|
|
3723
|
+
requestId2
|
|
3724
|
+
);
|
|
3725
|
+
const scopeCore = resolveCore();
|
|
3726
|
+
if (recorded && recorded.instance_id !== scopeCore?.id) {
|
|
3727
|
+
const owner = core.scopeCore(
|
|
3728
|
+
target2.root.flow,
|
|
3729
|
+
core.store.getScope(recorded.instance_id)
|
|
3730
|
+
);
|
|
3731
|
+
return dispatch(core, owner, from, handlers, input);
|
|
3732
|
+
}
|
|
3733
|
+
if (!scopeCore) {
|
|
3734
|
+
return handlers.inactive({ input });
|
|
3735
|
+
}
|
|
3736
|
+
return dispatch(core, scopeCore, from, handlers, input);
|
|
3737
|
+
}
|
|
3738
|
+
};
|
|
3739
|
+
}
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
};
|
|
3743
|
+
}
|
|
3744
|
+
function createInstanceApi(core, instance) {
|
|
3745
|
+
const definition = instance.flow.definition;
|
|
3746
|
+
const api = {
|
|
3747
|
+
instanceId: instance.id,
|
|
3748
|
+
scope: scopeNavigation(core, {
|
|
3749
|
+
root: instance,
|
|
3750
|
+
parentId: () => instance.id,
|
|
3751
|
+
graph: definition,
|
|
3752
|
+
path: ""
|
|
3753
|
+
}),
|
|
3754
|
+
getSnapshot: () => core.snapshot(instance),
|
|
3755
|
+
subscribe(listener) {
|
|
3756
|
+
const wrapped = (snapshot) => listener(snapshot);
|
|
3757
|
+
instance.listeners.add(wrapped);
|
|
3758
|
+
return () => {
|
|
3759
|
+
instance.listeners.delete(wrapped);
|
|
3760
|
+
};
|
|
3761
|
+
},
|
|
3762
|
+
history: (query) => core.history(instance.id, query),
|
|
3763
|
+
when(from) {
|
|
3764
|
+
const hasOutgoing = [...definition.transitionsByName.values()].some(
|
|
3765
|
+
(transition) => transition.from.name === from && transition.trigger === "dispatch"
|
|
3766
|
+
);
|
|
3767
|
+
if (!hasOutgoing) {
|
|
3768
|
+
throw new InputError(
|
|
3769
|
+
`State ${JSON.stringify(from)} has no outgoing transitions in flow "${definition.flowName}"`
|
|
3770
|
+
);
|
|
3771
|
+
}
|
|
3772
|
+
return {
|
|
3773
|
+
handle(handlers) {
|
|
3774
|
+
assertHandlers(handlers, DISPATCH_OUTCOMES);
|
|
3775
|
+
return {
|
|
3776
|
+
dispatch: (input) => dispatch(core, instance, from, handlers, input)
|
|
3777
|
+
};
|
|
3778
|
+
}
|
|
3779
|
+
};
|
|
3780
|
+
},
|
|
3781
|
+
handleResolve(handlers) {
|
|
3782
|
+
assertHandlers(handlers, ["accepted", "rejected"]);
|
|
3783
|
+
return {
|
|
3784
|
+
resolve: async (input) => {
|
|
3785
|
+
const result = await resolve(core, instance, input);
|
|
3786
|
+
return handlers[result.outcome](result);
|
|
3787
|
+
}
|
|
3788
|
+
};
|
|
3789
|
+
},
|
|
3790
|
+
handleRestart(handlers) {
|
|
3791
|
+
assertHandlers(handlers, ["restarted", "not-restarted"]);
|
|
3792
|
+
return {
|
|
3793
|
+
restart: () => handlers[core.restartRecovery(instance) ? "restarted" : "not-restarted"]({})
|
|
3794
|
+
};
|
|
3795
|
+
}
|
|
3796
|
+
};
|
|
3797
|
+
return api;
|
|
3798
|
+
}
|
|
3799
|
+
var DISPATCH_OUTCOMES = [
|
|
3800
|
+
"applied",
|
|
3801
|
+
"blocked",
|
|
3802
|
+
"invalid-payload",
|
|
3803
|
+
"busy",
|
|
3804
|
+
"recovery-pending",
|
|
3805
|
+
"needs-attention",
|
|
3806
|
+
"state-mismatch",
|
|
3807
|
+
"request-conflict",
|
|
3808
|
+
"terminated",
|
|
3809
|
+
"completed"
|
|
3810
|
+
];
|
|
3811
|
+
var SCOPED_DISPATCH_OUTCOMES = [
|
|
3812
|
+
...DISPATCH_OUTCOMES,
|
|
3813
|
+
"interrupted",
|
|
3814
|
+
"inactive"
|
|
3815
|
+
];
|
|
3816
|
+
function assertHandlers(handlers, expected) {
|
|
3817
|
+
const missing = expected.filter((key) => typeof handlers[key] !== "function");
|
|
3818
|
+
const extra = Object.keys(handlers).filter((key) => !expected.includes(key));
|
|
3819
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
3820
|
+
throw new InputError(
|
|
3821
|
+
`Outcome handlers must be exactly ${expected.join(", ")}` + (missing.length ? `; missing ${missing.join(", ")}` : "") + (extra.length ? `; unexpected ${extra.join(", ")}` : "")
|
|
3822
|
+
);
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
function requireWork(core, id) {
|
|
3826
|
+
const work = core.currentWork(id);
|
|
3827
|
+
if (!work) {
|
|
3828
|
+
throw new Error(`Instance ${id} has no unresolved work`);
|
|
3829
|
+
}
|
|
3830
|
+
return work;
|
|
3831
|
+
}
|
|
3832
|
+
function reasonOf(snapshot) {
|
|
3833
|
+
return snapshot.status.kind === "needs-attention" || snapshot.status.kind === "terminated" ? snapshot.status.reason : "";
|
|
3834
|
+
}
|
|
3835
|
+
function recordedDispatch(core, instance, handlers, input, requestId2, inputJson) {
|
|
3836
|
+
const recorded = core.store.findExecutionByRequest(instance.id, requestId2);
|
|
3837
|
+
if (!recorded) return { found: false };
|
|
3838
|
+
if (recorded.input_json !== inputJson) {
|
|
3839
|
+
const original = parseJson(recorded.input_json);
|
|
3840
|
+
return {
|
|
3841
|
+
found: true,
|
|
3842
|
+
value: handlers["request-conflict"]({ input, recorded: original })
|
|
3843
|
+
};
|
|
3844
|
+
}
|
|
3845
|
+
if (instance.activeExecutionId === recorded.execution_id && recorded.status !== "interrupted")
|
|
3846
|
+
return {
|
|
3847
|
+
found: true,
|
|
3848
|
+
value: handlers.busy({ input, snapshot: core.snapshot(instance) })
|
|
3849
|
+
};
|
|
3850
|
+
return {
|
|
3851
|
+
found: true,
|
|
3852
|
+
value: selectRecorded(core, instance, handlers, input, recorded, true)
|
|
3853
|
+
};
|
|
3854
|
+
}
|
|
3855
|
+
async function dispatch(core, instance, from, handlers, input) {
|
|
3856
|
+
core.assertOpen();
|
|
3857
|
+
const { definition } = instance.flow;
|
|
3858
|
+
const requestId2 = requestId(input.requestId);
|
|
3859
|
+
const transition = definition.transitionsByName.get(input.transition);
|
|
3860
|
+
if (!transition || transition.from.name !== from) {
|
|
3861
|
+
throw new InputError(
|
|
3862
|
+
`Transition ${JSON.stringify(input.transition)} does not leave "${from}" in flow "${definition.flowName}"`
|
|
3863
|
+
);
|
|
3864
|
+
}
|
|
3865
|
+
if (transition.trigger === "done") {
|
|
3866
|
+
throw new InputError(
|
|
3867
|
+
`Transition ${JSON.stringify(transition.name)} fires when the children of "${from}" complete; it cannot be dispatched`
|
|
3868
|
+
);
|
|
3869
|
+
}
|
|
3870
|
+
const payload = input.payload === void 0 ? null : input.payload;
|
|
3871
|
+
const problem = findNonJsonValue(payload);
|
|
3872
|
+
if (problem) {
|
|
3873
|
+
return handlers["invalid-payload"]({
|
|
3874
|
+
input,
|
|
3875
|
+
issues: [
|
|
3876
|
+
{ path: [], message: `Payload is not JSON-compatible: ${problem}` }
|
|
3877
|
+
]
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
const payloadJson = canonicalJson(payload);
|
|
3881
|
+
input = Object.freeze({
|
|
3882
|
+
requestId: requestId2,
|
|
3883
|
+
transition: transition.name,
|
|
3884
|
+
payload: parseJson(payloadJson)
|
|
3885
|
+
});
|
|
3886
|
+
const inputJson = canonicalJson({
|
|
3887
|
+
fromState: from,
|
|
3888
|
+
transition: transition.name,
|
|
3889
|
+
payload: input.payload
|
|
3890
|
+
});
|
|
3891
|
+
const previous = recordedDispatch(
|
|
3892
|
+
core,
|
|
3893
|
+
instance,
|
|
3894
|
+
handlers,
|
|
3895
|
+
input,
|
|
3896
|
+
requestId2,
|
|
3897
|
+
inputJson
|
|
3898
|
+
);
|
|
3899
|
+
if (previous.found) return previous.value;
|
|
3900
|
+
const parsed = await transition.payloadSchema.safeParseAsync(
|
|
3901
|
+
parseJson(payloadJson)
|
|
3902
|
+
);
|
|
3903
|
+
core.assertOpen();
|
|
3904
|
+
const concurrent = recordedDispatch(
|
|
3905
|
+
core,
|
|
3906
|
+
instance,
|
|
3907
|
+
handlers,
|
|
3908
|
+
input,
|
|
3909
|
+
requestId2,
|
|
3910
|
+
inputJson
|
|
3911
|
+
);
|
|
3912
|
+
if (concurrent.found) return concurrent.value;
|
|
3913
|
+
if (!parsed.success) {
|
|
3914
|
+
return handlers["invalid-payload"]({
|
|
3915
|
+
input,
|
|
3916
|
+
issues: parsed.error.issues.map(({ path, message }) => ({
|
|
3917
|
+
path,
|
|
3918
|
+
message
|
|
3919
|
+
}))
|
|
3920
|
+
});
|
|
3921
|
+
}
|
|
3922
|
+
const parsedProblem = findNonJsonValue(parsed.data);
|
|
3923
|
+
if (parsedProblem || canonicalJson(parsed.data) !== payloadJson) {
|
|
3924
|
+
return handlers["invalid-payload"]({
|
|
3925
|
+
input,
|
|
3926
|
+
issues: [
|
|
3927
|
+
{
|
|
3928
|
+
path: [],
|
|
3929
|
+
message: "Payload schemas must preserve the JSON input; coercion, defaults, transforms and stripped fields are not supported"
|
|
3930
|
+
}
|
|
3931
|
+
]
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3934
|
+
if (instance.activeExecutionId !== null) {
|
|
3935
|
+
return handlers.busy({ input, snapshot: core.snapshot(instance) });
|
|
3936
|
+
}
|
|
3937
|
+
const row = core.requireInstanceRow(instance.id);
|
|
3938
|
+
const snapshot = core.snapshot(instance);
|
|
3939
|
+
if (instance.scope !== null && row.active !== 1) {
|
|
3940
|
+
return handlers.inactive({ input });
|
|
3941
|
+
}
|
|
3942
|
+
switch (row.status) {
|
|
3943
|
+
case "completed":
|
|
3944
|
+
return handlers.completed({ input, snapshot });
|
|
3945
|
+
case "terminated":
|
|
3946
|
+
return handlers.terminated({
|
|
3947
|
+
input,
|
|
3948
|
+
snapshot,
|
|
3949
|
+
reason: row.status_reason ?? ""
|
|
3950
|
+
});
|
|
3951
|
+
case "needs-attention":
|
|
3952
|
+
return handlers["needs-attention"]({
|
|
3953
|
+
input,
|
|
3954
|
+
snapshot,
|
|
3955
|
+
work: requireWork(core, instance.id),
|
|
3956
|
+
reason: row.status_reason ?? ""
|
|
3957
|
+
});
|
|
3958
|
+
case "recovering":
|
|
3959
|
+
return handlers["recovery-pending"]({
|
|
3960
|
+
input,
|
|
3961
|
+
snapshot,
|
|
3962
|
+
work: requireWork(core, instance.id)
|
|
3963
|
+
});
|
|
3964
|
+
case "running":
|
|
3965
|
+
return handlers.busy({ input, snapshot });
|
|
3966
|
+
case "exited":
|
|
3967
|
+
return handlers.inactive({ input });
|
|
3968
|
+
case "ready":
|
|
3969
|
+
break;
|
|
3970
|
+
}
|
|
3971
|
+
if (row.state !== from) {
|
|
3972
|
+
return handlers["state-mismatch"]({
|
|
3973
|
+
input,
|
|
3974
|
+
expectedState: from,
|
|
3975
|
+
actualState: row.state,
|
|
3976
|
+
snapshot
|
|
3977
|
+
});
|
|
3978
|
+
}
|
|
3979
|
+
if (core.ancestorBusy(instance) || transition.from.composition !== null && transition.childWork === "block" && core.hasBusyDescendant(instance)) {
|
|
3980
|
+
return handlers.busy({ input, snapshot });
|
|
3981
|
+
}
|
|
3982
|
+
const executionId = mintExecutionId();
|
|
3983
|
+
core.store.transaction(() => {
|
|
3984
|
+
core.store.insertExecution({
|
|
3985
|
+
execution_id: executionId,
|
|
3986
|
+
instance_id: instance.id,
|
|
3987
|
+
kind: "dispatch",
|
|
3988
|
+
request_id: requestId2,
|
|
3989
|
+
input_json: inputJson,
|
|
3990
|
+
from_state: from,
|
|
3991
|
+
transition: transition.name,
|
|
3992
|
+
// Selected by onTransition for a branching transition; recorded before the commit.
|
|
3993
|
+
to_state: null,
|
|
3994
|
+
step: "guard",
|
|
3995
|
+
status: "running",
|
|
3996
|
+
handler_revision: instance.flow.root.handlerRevision
|
|
3997
|
+
});
|
|
3998
|
+
core.store.updateInstance(instance.id, {
|
|
3999
|
+
status: "running",
|
|
4000
|
+
current_execution_id: executionId
|
|
4001
|
+
});
|
|
4002
|
+
core.record(
|
|
4003
|
+
instance.id,
|
|
4004
|
+
"execution-started",
|
|
4005
|
+
{
|
|
4006
|
+
transition: transition.name,
|
|
4007
|
+
from,
|
|
4008
|
+
destinations: transition.destinations.map((state) => state.name),
|
|
4009
|
+
payload: input.payload,
|
|
4010
|
+
...transition.from.composition !== null ? { childWork: transition.childWork } : {}
|
|
4011
|
+
},
|
|
4012
|
+
{ requestId: requestId2 }
|
|
4013
|
+
);
|
|
4014
|
+
});
|
|
4015
|
+
core.notify(instance);
|
|
4016
|
+
await core.track(
|
|
4017
|
+
instance,
|
|
4018
|
+
executionId,
|
|
4019
|
+
() => core.advance(instance, executionId)
|
|
4020
|
+
);
|
|
4021
|
+
return selectRecorded(
|
|
4022
|
+
core,
|
|
4023
|
+
instance,
|
|
4024
|
+
handlers,
|
|
4025
|
+
input,
|
|
4026
|
+
core.requireExecution(executionId),
|
|
4027
|
+
false
|
|
4028
|
+
);
|
|
4029
|
+
}
|
|
4030
|
+
function selectRecorded(core, instance, handlers, input, execution, duplicate) {
|
|
4031
|
+
const snapshot = core.snapshot(instance);
|
|
4032
|
+
switch (execution.status) {
|
|
4033
|
+
case "applied":
|
|
4034
|
+
return handlers.applied({
|
|
4035
|
+
input,
|
|
4036
|
+
snapshot,
|
|
4037
|
+
duplicate,
|
|
4038
|
+
destination: execution.to_state
|
|
4039
|
+
});
|
|
4040
|
+
case "blocked":
|
|
4041
|
+
return handlers.blocked({ input, snapshot, duplicate });
|
|
4042
|
+
case "terminated":
|
|
4043
|
+
return handlers.terminated({
|
|
4044
|
+
input,
|
|
4045
|
+
snapshot,
|
|
4046
|
+
reason: reasonOf(snapshot)
|
|
4047
|
+
});
|
|
4048
|
+
case "interrupted":
|
|
4049
|
+
return handlers.interrupted({ input, snapshot, duplicate });
|
|
4050
|
+
case "needs-attention": {
|
|
4051
|
+
const work = core.currentWork(instance.id);
|
|
4052
|
+
if (work) {
|
|
4053
|
+
return handlers["needs-attention"]({
|
|
4054
|
+
input,
|
|
4055
|
+
snapshot,
|
|
4056
|
+
work,
|
|
4057
|
+
reason: reasonOf(snapshot)
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
return handlers.busy({ input, snapshot });
|
|
4061
|
+
}
|
|
4062
|
+
case "recovering":
|
|
4063
|
+
case "running": {
|
|
4064
|
+
const work = core.currentWork(instance.id);
|
|
4065
|
+
if (work && snapshot.status.kind === "recovering") {
|
|
4066
|
+
return handlers["recovery-pending"]({ input, snapshot, work });
|
|
4067
|
+
}
|
|
4068
|
+
return handlers.busy({ input, snapshot });
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
async function resolve(core, root, untrustedInput) {
|
|
4073
|
+
core.assertOpen();
|
|
4074
|
+
const input = parseResolveInput(untrustedInput);
|
|
4075
|
+
const instance = operationOwner(core, root, input.operationId);
|
|
4076
|
+
const row = core.requireInstanceRow(instance.id);
|
|
4077
|
+
const execution = row.current_execution_id === null ? void 0 : core.store.getExecution(row.current_execution_id);
|
|
4078
|
+
const operation = execution ? core.store.findUnresolvedOperation(execution.execution_id) : void 0;
|
|
4079
|
+
if (instance.activeExecutionId !== null || row.status !== "needs-attention" || !execution || !operation || operation.status !== "needs-attention" || operation.operation_id !== input.operationId || operation.attempts !== input.expectedAttempts) {
|
|
4080
|
+
return staleRejection(root.id, input, operation);
|
|
4081
|
+
}
|
|
4082
|
+
const fenced = execution.status === "interrupted";
|
|
4083
|
+
const refs = {
|
|
4084
|
+
requestId: execution.request_id,
|
|
4085
|
+
operationId: operation.operation_id
|
|
4086
|
+
};
|
|
4087
|
+
const audit = (data) => {
|
|
4088
|
+
core.record(
|
|
4089
|
+
instance.id,
|
|
4090
|
+
"manual-resolution",
|
|
4091
|
+
{ action: input.action, reason: input.reason, ...data },
|
|
4092
|
+
refs
|
|
4093
|
+
);
|
|
4094
|
+
if (instance !== root) {
|
|
4095
|
+
core.recordScopeEvent(instance, "manual-resolution", {
|
|
4096
|
+
action: input.action,
|
|
4097
|
+
reason: input.reason,
|
|
4098
|
+
operationId: operation.operation_id,
|
|
4099
|
+
...data
|
|
4100
|
+
});
|
|
4101
|
+
}
|
|
4102
|
+
};
|
|
4103
|
+
switch (input.action) {
|
|
4104
|
+
case "terminate":
|
|
4105
|
+
terminateTree(core, root.id, input.reason, audit);
|
|
4106
|
+
return { outcome: "accepted", snapshot: core.snapshot(root) };
|
|
4107
|
+
case "retry": {
|
|
4108
|
+
if (fenced && operation.strategy !== "reconcile") {
|
|
4109
|
+
return {
|
|
4110
|
+
outcome: "rejected",
|
|
4111
|
+
reason: "not-replayable",
|
|
4112
|
+
message: `Operation ${operation.operation_id} belongs to an exited activation; interrupted work is never replayed. Confirm its outcome or terminate.`
|
|
4113
|
+
};
|
|
4114
|
+
}
|
|
4115
|
+
await core.manualRetry(instance, execution, operation, () => audit({}));
|
|
4116
|
+
return { outcome: "accepted", snapshot: core.snapshot(root) };
|
|
4117
|
+
}
|
|
4118
|
+
case "confirm-completed": {
|
|
4119
|
+
if (input.callback !== operation.callback_kind) {
|
|
4120
|
+
return {
|
|
4121
|
+
outcome: "rejected",
|
|
4122
|
+
reason: "callback-mismatch",
|
|
4123
|
+
message: `Operation ${operation.operation_id} is ${operation.callback_kind}, not ${input.callback}`
|
|
4124
|
+
};
|
|
4125
|
+
}
|
|
4126
|
+
const supplied = "result" in input ? input.result : void 0;
|
|
4127
|
+
const validated = validateConfirmedResult(
|
|
4128
|
+
instance.flow.definition,
|
|
4129
|
+
operation.callback_kind,
|
|
4130
|
+
supplied,
|
|
4131
|
+
execution.transition,
|
|
4132
|
+
execution.to_state
|
|
4133
|
+
);
|
|
4134
|
+
if (!validated.ok) {
|
|
4135
|
+
return {
|
|
4136
|
+
outcome: "rejected",
|
|
4137
|
+
reason: "invalid-result",
|
|
4138
|
+
message: validated.message
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
4141
|
+
if (fenced) {
|
|
4142
|
+
core.store.transaction(() => {
|
|
4143
|
+
audit({ result: validated.result });
|
|
4144
|
+
core.settleLate(
|
|
4145
|
+
instance,
|
|
4146
|
+
execution,
|
|
4147
|
+
operation,
|
|
4148
|
+
null,
|
|
4149
|
+
validated.result,
|
|
4150
|
+
"confirmed",
|
|
4151
|
+
refs
|
|
4152
|
+
);
|
|
4153
|
+
});
|
|
4154
|
+
return { outcome: "accepted", snapshot: core.snapshot(root) };
|
|
4155
|
+
}
|
|
4156
|
+
core.store.transaction(() => {
|
|
4157
|
+
core.checkpoint(instance, execution, operation, validated.result);
|
|
4158
|
+
audit({ result: validated.result });
|
|
4159
|
+
});
|
|
4160
|
+
core.notify(instance);
|
|
4161
|
+
await core.track(
|
|
4162
|
+
instance,
|
|
4163
|
+
execution.execution_id,
|
|
4164
|
+
() => core.advance(instance, execution.execution_id)
|
|
4165
|
+
);
|
|
4166
|
+
return { outcome: "accepted", snapshot: core.snapshot(root) };
|
|
4167
|
+
}
|
|
4168
|
+
default:
|
|
4169
|
+
throw new InputError(
|
|
4170
|
+
`Unknown resolve action ${JSON.stringify(input.action)}`
|
|
4171
|
+
);
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
function operationOwner(core, root, operationId) {
|
|
4175
|
+
const operation = core.store.getOperation(operationId);
|
|
4176
|
+
if (!operation || operation.instance_id === root.id) return root;
|
|
4177
|
+
const scope = core.store.getScope(operation.instance_id);
|
|
4178
|
+
if (!scope || scope.root_instance_id !== root.id) return root;
|
|
4179
|
+
return core.scopeCore(root.flow, scope);
|
|
4180
|
+
}
|
|
4181
|
+
function staleRejection(instanceId2, input, operation) {
|
|
4182
|
+
const detail = operation && operation.operation_id === input.operationId ? ` (it has had ${operation.attempts} attempt(s); the action expected ${input.expectedAttempts})` : "";
|
|
4183
|
+
return {
|
|
4184
|
+
outcome: "rejected",
|
|
4185
|
+
reason: "stale",
|
|
4186
|
+
message: `Operation ${input.operationId} is not the work awaiting attention on instance ${instanceId2}${detail}`
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
function terminateTree(core, rootId, reason, audit) {
|
|
4190
|
+
const rows = [
|
|
4191
|
+
core.store.getScope(rootId),
|
|
4192
|
+
...core.store.listScopes(rootId)
|
|
4193
|
+
];
|
|
4194
|
+
core.store.transaction(() => {
|
|
4195
|
+
for (const row of rows) {
|
|
4196
|
+
const execution = row.current_execution_id === null ? void 0 : core.store.getExecution(row.current_execution_id);
|
|
4197
|
+
if (execution) {
|
|
4198
|
+
const operation = core.store.findUnresolvedOperation(
|
|
4199
|
+
execution.execution_id
|
|
4200
|
+
);
|
|
4201
|
+
if (operation) {
|
|
4202
|
+
core.store.updateOperation(operation.operation_id, {
|
|
4203
|
+
status: "terminated"
|
|
4204
|
+
});
|
|
4205
|
+
}
|
|
4206
|
+
if (execution.status !== "interrupted") {
|
|
4207
|
+
core.store.updateExecution(execution.execution_id, {
|
|
4208
|
+
status: "terminated"
|
|
4209
|
+
});
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
if (row.instance_id !== rootId) {
|
|
4213
|
+
core.store.updateScope(row.instance_id, { active: 0 });
|
|
4214
|
+
}
|
|
4215
|
+
if (row.instance_id === rootId || row.active === 1 || UNFINISHED.has(row.status)) {
|
|
4216
|
+
core.store.updateInstance(row.instance_id, {
|
|
4217
|
+
status: "terminated",
|
|
4218
|
+
status_reason: reason
|
|
4219
|
+
});
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
audit({});
|
|
4223
|
+
core.record(rootId, "instance-terminated", { reason });
|
|
4224
|
+
});
|
|
4225
|
+
for (const row of rows) {
|
|
4226
|
+
const loaded = core.instances.get(row.instance_id);
|
|
4227
|
+
if (!loaded) continue;
|
|
4228
|
+
core.clearTimer(loaded);
|
|
4229
|
+
if (loaded.scope !== null) {
|
|
4230
|
+
loaded.abort.abort(new InputError("The instance was terminated"));
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
const root = core.instances.get(rootId);
|
|
4234
|
+
if (root) core.notify(root);
|
|
4235
|
+
}
|
|
4236
|
+
function terminateUnloadable(core, instanceId2, input) {
|
|
4237
|
+
const owner = core.store.getOperation(input.operationId);
|
|
4238
|
+
const ownerRow = owner && owner.instance_id !== instanceId2 ? core.store.getScope(owner.instance_id) : void 0;
|
|
4239
|
+
const rowId = ownerRow && ownerRow.root_instance_id === instanceId2 ? ownerRow.instance_id : instanceId2;
|
|
4240
|
+
const row = core.requireInstanceRow(rowId);
|
|
4241
|
+
const cached = core.instances.get(rowId);
|
|
4242
|
+
const execution = row.current_execution_id === null ? void 0 : core.store.getExecution(row.current_execution_id);
|
|
4243
|
+
const operation = execution ? core.store.findUnresolvedOperation(execution.execution_id) : void 0;
|
|
4244
|
+
if (cached !== void 0 && cached.activeExecutionId !== null || row.status === "ready" || row.status === "terminated" || row.status === "completed" || row.status === "exited" || !execution || !operation || operation.operation_id !== input.operationId || operation.attempts !== input.expectedAttempts) {
|
|
4245
|
+
return staleRejection(instanceId2, input, operation);
|
|
4246
|
+
}
|
|
4247
|
+
terminateTree(
|
|
4248
|
+
core,
|
|
4249
|
+
instanceId2,
|
|
4250
|
+
input.reason,
|
|
4251
|
+
(data) => core.record(
|
|
4252
|
+
instanceId2,
|
|
4253
|
+
"manual-resolution",
|
|
4254
|
+
{ action: "terminate", reason: input.reason, unloadable: true, ...data },
|
|
4255
|
+
{ requestId: execution.request_id, operationId: operation.operation_id }
|
|
4256
|
+
)
|
|
4257
|
+
);
|
|
4258
|
+
return { outcome: "accepted", snapshot: core.snapshotOf(instanceId2) };
|
|
4259
|
+
}
|
|
4260
|
+
function validateConfirmedResult(definition, kind, result, transitionName, stateName) {
|
|
4261
|
+
const transition = kind === "on-transition" ? definition.transitionsByName.get(transitionName ?? "") : void 0;
|
|
4262
|
+
const producesResult = kind === "guard" || kind === "resolve-start-state" || kind === "children" || transition !== void 0 && isBranching(transition);
|
|
4263
|
+
if (!producesResult && result !== void 0 && result !== null) {
|
|
4264
|
+
return { ok: false, message: `${kind} does not produce a result` };
|
|
4265
|
+
}
|
|
4266
|
+
return validateCallbackResult(
|
|
4267
|
+
definition,
|
|
4268
|
+
kind,
|
|
4269
|
+
result,
|
|
4270
|
+
transitionName,
|
|
4271
|
+
stateName
|
|
4272
|
+
);
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
// src/durable/create-runtime.ts
|
|
4276
|
+
function createRuntime(options) {
|
|
4277
|
+
const { shutdownTimeoutMs, ...runtimeOptions } = options;
|
|
4278
|
+
const runtime = createUnmanagedRuntime(runtimeOptions);
|
|
4279
|
+
const close = runtime.close;
|
|
4280
|
+
const handover = runtime.handover;
|
|
4281
|
+
runtime.close = (options2) => close({ timeoutMs: options2?.timeoutMs ?? shutdownTimeoutMs });
|
|
4282
|
+
runtime.handover = (options2) => handover({ timeoutMs: options2?.timeoutMs ?? shutdownTimeoutMs });
|
|
4283
|
+
return Object.assign(runtime, {
|
|
4284
|
+
[Symbol.asyncDispose]: () => runtime.close()
|
|
4285
|
+
});
|
|
4286
|
+
}
|
|
4287
|
+
|
|
4288
|
+
// src/durable/scoped-runtime.ts
|
|
4289
|
+
async function withRuntime(options, run) {
|
|
4290
|
+
const runtime = createRuntime(options);
|
|
4291
|
+
let work;
|
|
4292
|
+
try {
|
|
4293
|
+
work = { ok: true, value: await run(runtime) };
|
|
4294
|
+
} catch (error) {
|
|
4295
|
+
work = { ok: false, error };
|
|
4296
|
+
}
|
|
4297
|
+
try {
|
|
4298
|
+
await runtime.close();
|
|
4299
|
+
} catch (error) {
|
|
4300
|
+
if (!work.ok)
|
|
4301
|
+
throw new AggregateError(
|
|
4302
|
+
[work.error, error],
|
|
4303
|
+
"Application work and State Flow shutdown both failed"
|
|
4304
|
+
);
|
|
4305
|
+
throw error;
|
|
4306
|
+
}
|
|
4307
|
+
if (!work.ok) throw work.error;
|
|
4308
|
+
return work.value;
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
// src/durable/recovery-target.ts
|
|
4312
|
+
function recoveryTarget(work) {
|
|
4313
|
+
const target2 = {
|
|
4314
|
+
operationId: work.operationId,
|
|
4315
|
+
expectedAttempts: work.attempts
|
|
4316
|
+
};
|
|
4317
|
+
const base = {
|
|
4318
|
+
retry: (reason) => ({ ...target2, action: "retry", reason }),
|
|
4319
|
+
terminate: (reason) => ({ ...target2, action: "terminate", reason })
|
|
4320
|
+
};
|
|
4321
|
+
switch (work.callback.kind) {
|
|
4322
|
+
case "guard":
|
|
4323
|
+
return Object.freeze({
|
|
4324
|
+
...base,
|
|
4325
|
+
callback: "guard",
|
|
4326
|
+
confirm: (result, reason) => ({
|
|
4327
|
+
...target2,
|
|
4328
|
+
action: "confirm-completed",
|
|
4329
|
+
callback: "guard",
|
|
4330
|
+
result,
|
|
4331
|
+
reason
|
|
4332
|
+
})
|
|
4333
|
+
});
|
|
4334
|
+
case "resolve-start-state":
|
|
4335
|
+
return Object.freeze({
|
|
4336
|
+
...base,
|
|
4337
|
+
callback: "resolve-start-state",
|
|
4338
|
+
confirm: (result, reason) => ({
|
|
4339
|
+
...target2,
|
|
4340
|
+
action: "confirm-completed",
|
|
4341
|
+
callback: "resolve-start-state",
|
|
4342
|
+
result,
|
|
4343
|
+
reason
|
|
4344
|
+
})
|
|
4345
|
+
});
|
|
4346
|
+
case "children":
|
|
4347
|
+
return Object.freeze({
|
|
4348
|
+
...base,
|
|
4349
|
+
callback: "children",
|
|
4350
|
+
confirm: (result, reason) => ({
|
|
4351
|
+
...target2,
|
|
4352
|
+
action: "confirm-completed",
|
|
4353
|
+
callback: "children",
|
|
4354
|
+
result,
|
|
4355
|
+
reason
|
|
4356
|
+
})
|
|
4357
|
+
});
|
|
4358
|
+
case "on-transition":
|
|
4359
|
+
return Object.freeze({
|
|
4360
|
+
...base,
|
|
4361
|
+
callback: "on-transition",
|
|
4362
|
+
confirm: (reason, destination) => ({
|
|
4363
|
+
...target2,
|
|
4364
|
+
action: "confirm-completed",
|
|
4365
|
+
callback: "on-transition",
|
|
4366
|
+
...destination === void 0 ? {} : { result: destination },
|
|
4367
|
+
reason
|
|
4368
|
+
})
|
|
4369
|
+
});
|
|
4370
|
+
default: {
|
|
4371
|
+
const callback = work.callback.kind;
|
|
4372
|
+
return Object.freeze({
|
|
4373
|
+
...base,
|
|
4374
|
+
callback,
|
|
4375
|
+
confirm: (reason) => ({
|
|
4376
|
+
...target2,
|
|
4377
|
+
action: "confirm-completed",
|
|
4378
|
+
callback,
|
|
4379
|
+
reason
|
|
4380
|
+
})
|
|
4381
|
+
});
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
export {
|
|
4386
|
+
DefinitionError,
|
|
4387
|
+
DefinitionMismatchError,
|
|
4388
|
+
InputError,
|
|
4389
|
+
MAX_ATTEMPTS,
|
|
4390
|
+
OwnershipError,
|
|
4391
|
+
RECOVERY_DELAYS_MS,
|
|
4392
|
+
RuntimeClosedError,
|
|
4393
|
+
RuntimeError,
|
|
4394
|
+
SchemaError,
|
|
4395
|
+
ShutdownTimeoutError,
|
|
4396
|
+
checkDeployment,
|
|
4397
|
+
createRuntime,
|
|
4398
|
+
createUnmanagedRuntime,
|
|
4399
|
+
instanceId,
|
|
4400
|
+
parseResolveInput,
|
|
4401
|
+
recoveryTarget,
|
|
4402
|
+
requestId,
|
|
4403
|
+
resolveInputSchema,
|
|
4404
|
+
withRuntime
|
|
4405
|
+
};
|