@orkestrel/workflow 0.0.16 → 0.0.18
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/README.md +12 -6
- package/dist/src/browser/index.d.ts +53 -52
- package/dist/src/browser/index.js +55 -72
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +1356 -833
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +1951 -1407
- package/dist/src/core/index.d.ts +1951 -1407
- package/dist/src/core/index.js +1341 -825
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +16 -21
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +17 -16
- package/dist/src/server/index.d.ts +17 -16
- package/dist/src/server/index.js +17 -22
- package/dist/src/server/index.js.map +1 -1
- package/package.json +20 -21
package/dist/src/core/index.js
CHANGED
|
@@ -1,38 +1,72 @@
|
|
|
1
1
|
import { createAbort, isAbortSignal, linkSignal } from "@orkestrel/abort";
|
|
2
|
-
import { arrayShape, attempt, cloneJSONRecord, cloneJSONValue, compileGuard, createContract, integerShape, isArray, isBoolean, isContractError, isFiniteNumber, isFunction, isInteger, isJSONValue, isNonEmptyString, isRecord, literalShape, objectShape, optionalShape, rawShape, stringShape } from "@orkestrel/contract";
|
|
2
|
+
import { arrayShape, attempt, cloneJSONRecord, cloneJSONValue, compileGuard, createContract, integerShape, isArray, isBoolean, isContractError, isFiniteNumber, isFunction, isInteger, isJSONValue, isNonEmptyString, isObject, isRecord, literalShape, objectShape, optionalShape, rawShape, stringShape } from "@orkestrel/contract";
|
|
3
3
|
import { createDatabase, createMemoryDriver } from "@orkestrel/database";
|
|
4
4
|
import { Emitter } from "@orkestrel/emitter";
|
|
5
5
|
import { createTimeout } from "@orkestrel/timeout";
|
|
6
6
|
import { createQueue } from "@orkestrel/queue";
|
|
7
|
+
//#region src/core/errors.ts
|
|
8
|
+
/**
|
|
9
|
+
* Represents an error the workflow runtime raises for an operation it refuses — a
|
|
10
|
+
* {@link WorkflowErrorCode} (`TRANSITION`, `RESTORE`, `MUTATION`, `SCHEDULE`, or `INVARIANT`)
|
|
11
|
+
* beside an optional `context` naming the node or the parameter at fault.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* Raised for an illegal lifecycle transition
|
|
15
|
+
* (`TRANSITION`), a structurally invalid {@link import('./types.js').WorkflowSnapshot}
|
|
16
|
+
* boundary (`RESTORE`), a refused structural/activity edit (`MUTATION`), a host
|
|
17
|
+
* schedule refused before arming because the caller's `signal` is not a native
|
|
18
|
+
* `AbortSignal` (`SCHEDULE`, delivered as a rejected promise), or a broken internal
|
|
19
|
+
* invariant (`INVARIANT`).
|
|
20
|
+
*/
|
|
21
|
+
var WorkflowError = class extends Error {
|
|
22
|
+
code;
|
|
23
|
+
context;
|
|
24
|
+
constructor(code, message, context) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "WorkflowError";
|
|
27
|
+
this.code = code;
|
|
28
|
+
if (context !== void 0) this.context = context;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Narrows an unknown caught value to a {@link WorkflowError}.
|
|
33
|
+
*
|
|
34
|
+
* @param value - The value to test (typically a `catch` binding)
|
|
35
|
+
* @returns True if `value` is a {@link WorkflowError}; false otherwise
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* try {
|
|
40
|
+
* task.complete('done')
|
|
41
|
+
* } catch (error) {
|
|
42
|
+
* if (isWorkflowError(error) && error.code === 'TRANSITION') retry()
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
function isWorkflowError(value) {
|
|
47
|
+
try {
|
|
48
|
+
return value instanceof WorkflowError;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
7
54
|
//#region src/core/constants.ts
|
|
8
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Names the default {@link import('./types.js').WorkflowDefinition.bail}, `false` — the graceful
|
|
57
|
+
* policy that records a leaf failure and finishes every phase.
|
|
58
|
+
*/
|
|
9
59
|
var DEFAULT_BAIL = false;
|
|
10
60
|
/**
|
|
11
|
-
*
|
|
61
|
+
* Lists every {@link LifecycleStatus} value, frozen — the vocabulary every tier draws from,
|
|
62
|
+
* in the order `pending`, `running`, `completed`, `failed`, `skipped`, `stopped`.
|
|
12
63
|
*
|
|
13
64
|
* @remarks
|
|
14
65
|
* Ordered pending → running → terminal (`completed` / `failed` / `skipped` /
|
|
15
|
-
* `stopped`). The source of truth for the union
|
|
66
|
+
* `stopped`). The runtime source of truth for the union:
|
|
67
|
+
* {@link import('./validators.js').isLifecycleStatus} reads this array.
|
|
16
68
|
*/
|
|
17
|
-
var
|
|
18
|
-
"pending",
|
|
19
|
-
"running",
|
|
20
|
-
"completed",
|
|
21
|
-
"failed",
|
|
22
|
-
"skipped",
|
|
23
|
-
"stopped"
|
|
24
|
-
]);
|
|
25
|
-
/** Every {@link PhaseStatus} value, frozen — the lifecycle vocabulary of a phase. */
|
|
26
|
-
var PHASE_STATUSES = Object.freeze([
|
|
27
|
-
"pending",
|
|
28
|
-
"running",
|
|
29
|
-
"completed",
|
|
30
|
-
"failed",
|
|
31
|
-
"skipped",
|
|
32
|
-
"stopped"
|
|
33
|
-
]);
|
|
34
|
-
/** Every {@link WorkflowStatus} value, frozen — the lifecycle vocabulary of a workflow. */
|
|
35
|
-
var WORKFLOW_STATUSES = Object.freeze([
|
|
69
|
+
var LIFECYCLE_STATUSES = Object.freeze([
|
|
36
70
|
"pending",
|
|
37
71
|
"running",
|
|
38
72
|
"completed",
|
|
@@ -41,21 +75,21 @@ var WORKFLOW_STATUSES = Object.freeze([
|
|
|
41
75
|
"stopped"
|
|
42
76
|
]);
|
|
43
77
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
78
|
+
* Lists the terminal {@link LifecycleStatus} values, frozen — `completed`, `failed`, `skipped`,
|
|
79
|
+
* and `stopped`, each a state a node never transitions out of.
|
|
46
80
|
*
|
|
47
81
|
* @remarks
|
|
48
82
|
* The source of truth behind {@link import('./helpers.js').isTerminalStatus}.
|
|
49
83
|
* `pending` and `running` are the only non-terminal members.
|
|
50
84
|
*/
|
|
51
|
-
var
|
|
85
|
+
var TERMINAL_STATUSES = Object.freeze([
|
|
52
86
|
"completed",
|
|
53
87
|
"failed",
|
|
54
88
|
"skipped",
|
|
55
89
|
"stopped"
|
|
56
90
|
]);
|
|
57
91
|
/**
|
|
58
|
-
*
|
|
92
|
+
* Declares the legal {@link LifecycleStatus} transition graph of the live W-b task state machine —
|
|
59
93
|
* each current status mapped to the statuses it may move to directly, frozen.
|
|
60
94
|
*
|
|
61
95
|
* @remarks
|
|
@@ -85,94 +119,161 @@ var TASK_TRANSITIONS = Object.freeze({
|
|
|
85
119
|
stopped: []
|
|
86
120
|
});
|
|
87
121
|
/**
|
|
88
|
-
*
|
|
122
|
+
* Names the default per-phase task concurrency the {@link import('./factories.js').createWorkflowRunner}
|
|
89
123
|
* runner applies when a {@link import('./types.js').PhaseDefinition} omits its `concurrency`
|
|
90
|
-
* throttle — a cap that is effectively unbounded for any realistic phase.
|
|
124
|
+
* throttle — `1024`, a cap that is effectively unbounded for any realistic phase.
|
|
91
125
|
*
|
|
92
126
|
* @remarks
|
|
93
|
-
* The determinism principle fixes that a phase's tasks run
|
|
127
|
+
* The determinism principle fixes that a phase's tasks run concurrently; `concurrency` is
|
|
94
128
|
* only an optional resource throttle (max-in-flight). With none declared, the runner runs
|
|
95
129
|
* all of a phase's tasks at once — modelled as this finite cap so the value flows straight
|
|
96
130
|
* into the substrate {@link import('./types.js').RunnerInterface}'s `concurrency` (which
|
|
97
131
|
* expects a positive integer) without a special unbounded branch. No realistic phase
|
|
98
132
|
* declares enough tasks to reach it, so it behaves as "run them all".
|
|
99
133
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
134
|
+
* why `1024` and not a huge sentinel like `1_000_000`: the backing `@orkestrel/queue` Runner
|
|
135
|
+
* eagerly spawns one parked worker loop per concurrency unit at construction, so this default
|
|
102
136
|
* must be a value whose eager allocation cost is negligible for every default-concurrency
|
|
103
137
|
* phase — a million-unit default meant ~1e6 promise/closure allocations per such phase. A
|
|
104
|
-
* phase may still
|
|
138
|
+
* phase may still declare a larger explicit `concurrency` and pays that allocation knowingly.
|
|
105
139
|
*/
|
|
106
140
|
var DEFAULT_PHASE_CONCURRENCY = 1024;
|
|
107
141
|
/**
|
|
108
|
-
*
|
|
142
|
+
* Names the largest delay representable by the host timer APIs without overflow or clamping,
|
|
143
|
+
* `2_147_483_647` milliseconds.
|
|
109
144
|
*/
|
|
110
145
|
var MAX_TIMER_MS = 2147483647;
|
|
146
|
+
/**
|
|
147
|
+
* Lists the {@link WorkflowEventMap} / {@link PhaseEventMap} events that make a durable observer
|
|
148
|
+
* re-persist the live tree, frozen — `start`, `complete`, `fail`, `skip`, `stop`, `move`, and
|
|
149
|
+
* `update`.
|
|
150
|
+
*
|
|
151
|
+
* @remarks
|
|
152
|
+
* The two maps carry the same event names, so one list serves both tiers. It is the source of
|
|
153
|
+
* truth behind {@link import('./WorkflowPersistence.js').WorkflowPersistence}'s attach and detach
|
|
154
|
+
* passes: subscribing and unsubscribing loop over these names, so an added event reaches both
|
|
155
|
+
* passes from one edit. `add` and `remove` are deliberately absent — they carry the new or dropped
|
|
156
|
+
* child, so the persistence layer binds its own attaching handler to them instead.
|
|
157
|
+
*/
|
|
158
|
+
var PERSISTED_NODE_EVENTS = Object.freeze([
|
|
159
|
+
"start",
|
|
160
|
+
"complete",
|
|
161
|
+
"fail",
|
|
162
|
+
"skip",
|
|
163
|
+
"stop",
|
|
164
|
+
"move",
|
|
165
|
+
"update"
|
|
166
|
+
]);
|
|
167
|
+
/**
|
|
168
|
+
* Lists the {@link TaskEventMap} events that make a durable observer re-persist the live tree,
|
|
169
|
+
* frozen — `start`, `complete`, `fail`, `skip`, `stop`, `report`, and `pulse`.
|
|
170
|
+
*
|
|
171
|
+
* @remarks
|
|
172
|
+
* The leaf counterpart of {@link PERSISTED_NODE_EVENTS}, and the source of truth behind the task
|
|
173
|
+
* attach and detach passes of
|
|
174
|
+
* {@link import('./WorkflowPersistence.js').WorkflowPersistence}. `report` and `pulse` join the
|
|
175
|
+
* lifecycle events because an accepted activity frame changes the persisted snapshot; a leaf has
|
|
176
|
+
* no children, so there is no structural event to bind separately.
|
|
177
|
+
*/
|
|
178
|
+
var PERSISTED_TASK_EVENTS = Object.freeze([
|
|
179
|
+
"start",
|
|
180
|
+
"complete",
|
|
181
|
+
"fail",
|
|
182
|
+
"skip",
|
|
183
|
+
"stop",
|
|
184
|
+
"report",
|
|
185
|
+
"pulse"
|
|
186
|
+
]);
|
|
111
187
|
//#endregion
|
|
112
|
-
//#region src/core/
|
|
188
|
+
//#region src/core/validators.ts
|
|
113
189
|
/**
|
|
114
|
-
*
|
|
190
|
+
* Checks whether an unknown value belongs to the workflow lifecycle vocabulary.
|
|
115
191
|
*
|
|
116
192
|
* @remarks
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
193
|
+
* Reads {@link import('./constants.js').LIFECYCLE_STATUSES}, the runtime array every tier draws
|
|
194
|
+
* from, so the vocabulary has one definition rather than a hard-coded copy per guard.
|
|
195
|
+
*
|
|
196
|
+
* @param value - The value to test
|
|
197
|
+
* @returns True if `value` is a {@link LifecycleStatus}; false otherwise
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* isLifecycleStatus('running') // true
|
|
202
|
+
* isLifecycleStatus('paused') // false
|
|
203
|
+
* ```
|
|
123
204
|
*/
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
constructor(code, message, context) {
|
|
128
|
-
super(message);
|
|
129
|
-
this.name = "WorkflowError";
|
|
130
|
-
this.code = code;
|
|
131
|
-
if (context !== void 0) this.context = context;
|
|
132
|
-
}
|
|
133
|
-
};
|
|
205
|
+
function isLifecycleStatus(value) {
|
|
206
|
+
return LIFECYCLE_STATUSES.some((status) => status === value);
|
|
207
|
+
}
|
|
134
208
|
/**
|
|
135
|
-
*
|
|
209
|
+
* Tests a normalized persisted task failure.
|
|
136
210
|
*
|
|
137
|
-
* @
|
|
138
|
-
*
|
|
211
|
+
* @remarks
|
|
212
|
+
* The exact-record guard behind a persisted {@link TaskFailure}: exactly `origin` and `message`,
|
|
213
|
+
* an `origin` drawn from the {@link import('./types.js').TaskFailureOrigin} vocabulary, and a
|
|
214
|
+
* non-empty `message`. Total — a hostile prototype or accessor answers `false` rather than
|
|
215
|
+
* throwing.
|
|
216
|
+
*
|
|
217
|
+
* @param value - The value to test
|
|
218
|
+
* @returns True if `value` is a persisted {@link TaskFailure}; false otherwise
|
|
139
219
|
*
|
|
140
220
|
* @example
|
|
141
221
|
* ```ts
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* } catch (error) {
|
|
145
|
-
* if (isWorkflowError(error) && error.code === 'TRANSITION') retry()
|
|
146
|
-
* }
|
|
222
|
+
* isTaskFailure({ origin: 'handler', message: 'boom' }) // true
|
|
223
|
+
* isTaskFailure({ origin: 'handler' }) // false
|
|
147
224
|
* ```
|
|
148
225
|
*/
|
|
149
|
-
function
|
|
226
|
+
function isTaskFailure(value) {
|
|
150
227
|
try {
|
|
151
|
-
return value
|
|
228
|
+
return isRecord(value) && Object.keys(value).every((key) => key === "origin" || key === "message") && (value.origin === "handler" || value.origin === "timeout" || value.origin === "recovery") && isNonEmptyString(value.message);
|
|
152
229
|
} catch {
|
|
153
230
|
return false;
|
|
154
231
|
}
|
|
155
232
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Checks whether an unknown value is a live workflow entity rather than a definition.
|
|
235
|
+
*
|
|
236
|
+
* @remarks
|
|
237
|
+
* The discriminator behind the overloaded
|
|
238
|
+
* {@link import('./types.js').WorkflowRunnerInterface.execute}: a
|
|
239
|
+
* {@link import('./types.js').WorkflowInterface} is the only one of the two carrying `destroyed`
|
|
240
|
+
* (runtime-only, never a field on the pure-JSON
|
|
241
|
+
* {@link import('./types.js').WorkflowDefinition}) and a callable `snapshot`. Requiring both is
|
|
242
|
+
* sturdier than `destroyed` alone — a definition could coincidentally carry a `destroyed` field as
|
|
243
|
+
* arbitrary data, and pairing it with a function-typed `snapshot` narrows to the actual entity
|
|
244
|
+
* shape without an `as`. It reads a live class instance, so it tests object identity rather than a
|
|
245
|
+
* plain-record brand, and it is total: any other value answers `false`.
|
|
246
|
+
*
|
|
247
|
+
* @param value - The value to test
|
|
248
|
+
* @returns True if `value` is a live {@link WorkflowInterface}; false otherwise
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* ```ts
|
|
252
|
+
* isWorkflowInterface(createWorkflow(definition)) // true
|
|
253
|
+
* isWorkflowInterface(definition) // false
|
|
254
|
+
* ```
|
|
255
|
+
*/
|
|
256
|
+
function isWorkflowInterface(value) {
|
|
164
257
|
try {
|
|
165
|
-
return
|
|
258
|
+
return isObject(value) && "destroyed" in value && "snapshot" in value && isFunction(value.snapshot);
|
|
166
259
|
} catch {
|
|
167
260
|
return false;
|
|
168
261
|
}
|
|
169
262
|
}
|
|
170
263
|
/**
|
|
171
|
-
*
|
|
264
|
+
* Validates a safe owned JSON graph as a coherent workflow snapshot.
|
|
172
265
|
*
|
|
173
266
|
* @remarks
|
|
174
267
|
* Callers at hostile boundaries use {@link isWorkflowSnapshot}, which owns the
|
|
175
268
|
* graph first so this semantic pass never observes accessors or prototypes.
|
|
269
|
+
*
|
|
270
|
+
* @param value - The already-owned JSON graph to validate
|
|
271
|
+
* @returns True if `value` is a coherent {@link WorkflowSnapshot}; false otherwise
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* ```ts
|
|
275
|
+
* isOwnedWorkflowSnapshot(workflow.snapshot()) // true
|
|
276
|
+
* ```
|
|
176
277
|
*/
|
|
177
278
|
function isOwnedWorkflowSnapshot(value) {
|
|
178
279
|
try {
|
|
@@ -194,7 +295,7 @@ function isOwnedWorkflowSnapshot(value) {
|
|
|
194
295
|
const statuses = [];
|
|
195
296
|
if (phase.tasks.length > 0) vacuous = false;
|
|
196
297
|
for (const task of phase.tasks) {
|
|
197
|
-
if (!isRecord(task) || !Object.keys(task).every((key) => key === "id" || key === "name" || key === "description" || key === "status" || key === "result" || key === "metadata" || key === "attempts" || key === "
|
|
298
|
+
if (!isRecord(task) || !Object.keys(task).every((key) => key === "id" || key === "name" || key === "description" || key === "status" || key === "result" || key === "metadata" || key === "attempts" || key === "behavior" || key === "retries" || key === "timeout" || key === "activity") || !isNonEmptyString(task.id) || taskIds.has(task.id) || !isNonEmptyString(task.name) || task.description !== void 0 && typeof task.description !== "string" || !isLifecycleStatus(task.status) || !isRecord(task.metadata) || !isJSONValue(task.metadata) || !isInteger(task.attempts) || task.attempts < 0 || task.behavior !== void 0 && !isNonEmptyString(task.behavior) || task.retries !== void 0 && (!isInteger(task.retries) || task.retries < 0) || task.timeout !== void 0 && (!isInteger(task.timeout) || task.timeout < 0 || task.timeout > 2147483647)) return false;
|
|
198
299
|
const budget = (task.retries ?? 0) + 1;
|
|
199
300
|
if (task.attempts > budget || task.status === "pending" && task.attempts >= budget) return false;
|
|
200
301
|
if (!(task.activity === void 0 || isTaskActivity(task.activity))) return false;
|
|
@@ -222,13 +323,82 @@ function isOwnedWorkflowSnapshot(value) {
|
|
|
222
323
|
return false;
|
|
223
324
|
}
|
|
224
325
|
}
|
|
225
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* Guards the hostile boundary totally for a workflow snapshot.
|
|
328
|
+
*
|
|
329
|
+
* @remarks
|
|
330
|
+
* Owns the value first through the exact-JSON clone of `@orkestrel/contract`, then runs the
|
|
331
|
+
* semantic pass {@link isOwnedWorkflowSnapshot} over the owned copy — so no accessor, prototype,
|
|
332
|
+
* or cycle in the caller's graph is ever observed by the semantic pass. Total: an unclonable
|
|
333
|
+
* value answers `false` rather than throwing.
|
|
334
|
+
*
|
|
335
|
+
* @param value - The untrusted value to test
|
|
336
|
+
* @returns True if `value` is a coherent {@link WorkflowSnapshot}; false otherwise
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* isWorkflowSnapshot(JSON.parse(payload)) // true only for a coherent snapshot
|
|
341
|
+
* ```
|
|
342
|
+
*/
|
|
226
343
|
function isWorkflowSnapshot(value) {
|
|
227
344
|
const cloned = attempt(() => cloneJSONValue(value));
|
|
228
345
|
return cloned.success && isOwnedWorkflowSnapshot(cloned.value);
|
|
229
346
|
}
|
|
230
347
|
/**
|
|
231
|
-
*
|
|
348
|
+
* Checks whether an unknown value is a valid list of task activity claims.
|
|
349
|
+
*
|
|
350
|
+
* @remarks
|
|
351
|
+
* The one guard behind both claim lists of a {@link TaskActivityInput} — its `operations` and its
|
|
352
|
+
* `constraints` — because {@link import('./types.js').TaskOperation} and
|
|
353
|
+
* {@link import('./types.js').TaskConstraint} are the same {@link TaskClaim} shape. Every member must be a plain record carrying exactly `id`, `name`, and
|
|
354
|
+
* `started`, with non-empty string `id` and `name`, a finite non-negative `started`, and an `id`
|
|
355
|
+
* unique within the list. Total: a hostile prototype, an accessor, or a cycle returns `false`
|
|
356
|
+
* rather than throwing.
|
|
357
|
+
*
|
|
358
|
+
* @param value - The value to test
|
|
359
|
+
* @returns True if `value` is a list of valid, uniquely identified claims; false otherwise
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```ts
|
|
363
|
+
* isTaskClaimList([{ id: 'fetch', name: 'Fetch', started: 1 }]) // true
|
|
364
|
+
* isTaskClaimList([{ id: 'fetch', name: 'Fetch' }]) // false
|
|
365
|
+
* ```
|
|
366
|
+
*/
|
|
367
|
+
function isTaskClaimList(value) {
|
|
368
|
+
try {
|
|
369
|
+
if (!isArray(value)) return false;
|
|
370
|
+
const ids = /* @__PURE__ */ new Set();
|
|
371
|
+
for (const claim of value) {
|
|
372
|
+
if (!isRecord(claim)) return false;
|
|
373
|
+
const prototype = Object.getPrototypeOf(claim);
|
|
374
|
+
if (prototype !== Object.prototype && prototype !== null || !Object.keys(claim).every((key) => key === "id" || key === "name" || key === "started")) return false;
|
|
375
|
+
const id = claim.id;
|
|
376
|
+
const name = claim.name;
|
|
377
|
+
const started = claim.started;
|
|
378
|
+
if (!isNonEmptyString(id) || !isNonEmptyString(name) || !isFiniteNumber(started) || started < 0 || ids.has(id)) return false;
|
|
379
|
+
ids.add(id);
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
} catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Tests whether an unknown value is a valid whole-frame activity report.
|
|
388
|
+
*
|
|
389
|
+
* @remarks
|
|
390
|
+
* The guard behind {@link import('./types.js').TaskInterface.report}: exactly the optional `note`,
|
|
391
|
+
* `progress`, `operations`, and `constraints` keys, with the two claim lists checked by
|
|
392
|
+
* {@link isTaskClaimList} and `progress` a finite non-negative value under an optional `total` at
|
|
393
|
+
* least as large. Total — a hostile prototype or accessor answers `false` rather than throwing.
|
|
394
|
+
*
|
|
395
|
+
* @param value - The value to test
|
|
396
|
+
* @returns True if `value` is a valid {@link TaskActivityInput}; false otherwise
|
|
397
|
+
*
|
|
398
|
+
* @example
|
|
399
|
+
* ```ts
|
|
400
|
+
* isTaskActivityInput({ note: 'compiling', progress: { progress: 2, total: 5 } }) // true
|
|
401
|
+
* ```
|
|
232
402
|
*/
|
|
233
403
|
function isTaskActivityInput(value) {
|
|
234
404
|
try {
|
|
@@ -249,41 +419,29 @@ function isTaskActivityInput(value) {
|
|
|
249
419
|
const message = progress.message;
|
|
250
420
|
if (!isFiniteNumber(reported) || reported < 0 || total !== void 0 && (!isFiniteNumber(total) || total < reported) || message !== void 0 && !isNonEmptyString(message)) return false;
|
|
251
421
|
}
|
|
252
|
-
if (operations !== void 0)
|
|
253
|
-
|
|
254
|
-
const ids = /* @__PURE__ */ new Set();
|
|
255
|
-
for (const operation of operations) {
|
|
256
|
-
if (!isRecord(operation)) return false;
|
|
257
|
-
const operationPrototype = Object.getPrototypeOf(operation);
|
|
258
|
-
if (operationPrototype !== Object.prototype && operationPrototype !== null || !Object.keys(operation).every((key) => key === "id" || key === "name" || key === "started")) return false;
|
|
259
|
-
const id = operation.id;
|
|
260
|
-
const name = operation.name;
|
|
261
|
-
const started = operation.started;
|
|
262
|
-
if (!isNonEmptyString(id) || !isNonEmptyString(name) || !isFiniteNumber(started) || started < 0 || ids.has(id)) return false;
|
|
263
|
-
ids.add(id);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
if (constraints !== void 0) {
|
|
267
|
-
if (!isArray(constraints)) return false;
|
|
268
|
-
const ids = /* @__PURE__ */ new Set();
|
|
269
|
-
for (const constraint of constraints) {
|
|
270
|
-
if (!isRecord(constraint)) return false;
|
|
271
|
-
const constraintPrototype = Object.getPrototypeOf(constraint);
|
|
272
|
-
if (constraintPrototype !== Object.prototype && constraintPrototype !== null || !Object.keys(constraint).every((key) => key === "id" || key === "name" || key === "started")) return false;
|
|
273
|
-
const id = constraint.id;
|
|
274
|
-
const name = constraint.name;
|
|
275
|
-
const started = constraint.started;
|
|
276
|
-
if (!isNonEmptyString(id) || !isNonEmptyString(name) || !isFiniteNumber(started) || started < 0 || ids.has(id)) return false;
|
|
277
|
-
ids.add(id);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
422
|
+
if (operations !== void 0 && !isTaskClaimList(operations)) return false;
|
|
423
|
+
if (constraints !== void 0 && !isTaskClaimList(constraints)) return false;
|
|
280
424
|
return true;
|
|
281
425
|
} catch {
|
|
282
426
|
return false;
|
|
283
427
|
}
|
|
284
428
|
}
|
|
285
429
|
/**
|
|
286
|
-
*
|
|
430
|
+
* Tests whether an unknown value is valid persisted task activity.
|
|
431
|
+
*
|
|
432
|
+
* @remarks
|
|
433
|
+
* The persisted counterpart of {@link isTaskActivityInput}: the same frame plus the required
|
|
434
|
+
* `operations`, `constraints`, and a finite non-negative `updated` stamp, because a stored frame
|
|
435
|
+
* has already been accepted and normalized. Total — a hostile prototype or accessor answers
|
|
436
|
+
* `false` rather than throwing.
|
|
437
|
+
*
|
|
438
|
+
* @param value - The value to test
|
|
439
|
+
* @returns True if `value` is a persisted {@link TaskActivity}; false otherwise
|
|
440
|
+
*
|
|
441
|
+
* @example
|
|
442
|
+
* ```ts
|
|
443
|
+
* isTaskActivity({ operations: [], constraints: [], updated: 1 }) // true
|
|
444
|
+
* ```
|
|
287
445
|
*/
|
|
288
446
|
function isTaskActivity(value) {
|
|
289
447
|
try {
|
|
@@ -309,7 +467,7 @@ function isTaskActivity(value) {
|
|
|
309
467
|
//#endregion
|
|
310
468
|
//#region src/core/helpers.ts
|
|
311
469
|
/**
|
|
312
|
-
*
|
|
470
|
+
* Captures every top-level {@link WorkflowOptions} value exactly once into an owned plain bag.
|
|
313
471
|
*
|
|
314
472
|
* @remarks
|
|
315
473
|
* Direct property reads preserve inherited and non-enumerable option values while preventing
|
|
@@ -344,32 +502,159 @@ function captureWorkflowOptions(options) {
|
|
|
344
502
|
});
|
|
345
503
|
}
|
|
346
504
|
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
505
|
+
* Tests whether a {@link LifecycleStatus} is terminal — `completed`, `failed`, `skipped`, or
|
|
506
|
+
* `stopped`, the states a node never transitions out of.
|
|
349
507
|
*
|
|
350
508
|
* @remarks
|
|
351
|
-
* The
|
|
509
|
+
* The one terminal check across every tier (AGENTS.md § Design laws, "one concept, one term"):
|
|
352
510
|
* a task, a phase, and a workflow share the same {@link LifecycleStatus} vocabulary, so a
|
|
353
511
|
* single predicate covers them — {@link derivePhaseStatus} and {@link deriveWorkflowStatus}
|
|
354
|
-
* both consult it to tell a settled node from an in-flight one.
|
|
355
|
-
* `
|
|
356
|
-
* `
|
|
512
|
+
* both consult it to tell a settled node from an in-flight one. It reads the terminal set from
|
|
513
|
+
* {@link import('./constants.js').TERMINAL_STATUSES} (`completed` / `failed` / `skipped` /
|
|
514
|
+
* `stopped`), so that constant is the one definition; the only non-terminal states are `pending`
|
|
515
|
+
* and `running`.
|
|
357
516
|
*
|
|
358
517
|
* @param status - The lifecycle status to test (a task / phase / workflow status)
|
|
359
|
-
* @returns
|
|
518
|
+
* @returns True if the status is terminal; false otherwise
|
|
360
519
|
*/
|
|
361
520
|
function isTerminalStatus(status) {
|
|
362
|
-
return status
|
|
521
|
+
return TERMINAL_STATUSES.includes(status);
|
|
363
522
|
}
|
|
364
523
|
/**
|
|
365
|
-
*
|
|
366
|
-
*
|
|
524
|
+
* Tests whether a driving run must stop giving a workflow — or one forced phase of it —
|
|
525
|
+
* more work.
|
|
526
|
+
*
|
|
527
|
+
* @remarks
|
|
528
|
+
* The halt gate a {@link import('./WorkflowRunner.js').WorkflowRunner} consults before starting a
|
|
529
|
+
* phase, before dispatching a task, and after every cooperative gate. A workflow is halted after
|
|
530
|
+
* its derived status is terminal but not `completed` — a `bail: true` failure, a caller's own
|
|
531
|
+
* graceful `stop()`, or a forced `skip`. `completed` is excluded deliberately: a workflow that
|
|
532
|
+
* completed vacuously is settled, not halted, and the distinction is what keeps the run from
|
|
533
|
+
* sweeping a finished tree. When a `phase` is supplied, its own forced `skipped` / `stopped` halts
|
|
534
|
+
* that phase's work too; a `failed` phase does not, because the workflow's own `bail` policy
|
|
535
|
+
* decides whether a failed phase ends the run.
|
|
536
|
+
*
|
|
537
|
+
* @param workflow - The live workflow the run is driving
|
|
538
|
+
* @param phase - The phase whose own forced terminal status also halts its tasks
|
|
539
|
+
* @returns True if the run must stop giving this workflow (or phase) more work; false otherwise
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* ```ts
|
|
543
|
+
* isHalted(workflow) // false while pending or running
|
|
544
|
+
* workflow.stop()
|
|
545
|
+
* isHalted(workflow) // true
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
function isHalted(workflow, phase) {
|
|
549
|
+
const status = workflow.status;
|
|
550
|
+
return isTerminalStatus(status) && status !== "completed" || phase?.status === "skipped" || phase?.status === "stopped";
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Tests whether forcing a workflow `stopped` would still record the cancellation.
|
|
554
|
+
*
|
|
555
|
+
* @remarks
|
|
556
|
+
* `stop()` is a no-op after a workflow's status becomes terminal, so a run that must record a
|
|
557
|
+
* cancellation forces it only while this holds. It is not the negation of
|
|
558
|
+
* {@link isTerminalStatus}: `completed` and `skipped` both pass, because a run-level cancel that
|
|
559
|
+
* lands on a vacuously-completed or fully-skipped tree still records `stopped` as the outcome the
|
|
560
|
+
* caller asked for. Only an already-`failed` or already-`stopped` workflow has a terminal state
|
|
561
|
+
* worth keeping.
|
|
562
|
+
*
|
|
563
|
+
* @param workflow - The live workflow a run-level cancel would force
|
|
564
|
+
* @returns True if forcing `stopped` would change the recorded outcome; false otherwise
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* ```ts
|
|
568
|
+
* isStoppable(workflow) // true while pending, running, completed, or skipped
|
|
569
|
+
* workflow.stop()
|
|
570
|
+
* isStoppable(workflow) // false
|
|
571
|
+
* ```
|
|
572
|
+
*/
|
|
573
|
+
function isStoppable(workflow) {
|
|
574
|
+
const status = workflow.status;
|
|
575
|
+
return status !== "failed" && status !== "stopped";
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Tests whether a naturally-finished run may force its workflow `completed`.
|
|
579
|
+
*
|
|
580
|
+
* @remarks
|
|
581
|
+
* A run that walked every phase and still derives `pending` executed nothing — zero phases, or
|
|
582
|
+
* every phase empty — so it is vacuously done and the run settles it `completed`. Gated on
|
|
583
|
+
* exactly `pending` so a real `completed`, a `bail: true` `failed`, a `stopped`, or a derived
|
|
584
|
+
* `skipped` is never overridden. The tree-is-empty half of the rule is
|
|
585
|
+
* {@link WorkflowInterface.complete}'s own guard, which refuses a pending tree that still holds
|
|
586
|
+
* tasks.
|
|
587
|
+
*
|
|
588
|
+
* @param workflow - The live workflow the run has finished walking
|
|
589
|
+
* @returns True if the run may force the vacuous completion; false otherwise
|
|
590
|
+
*
|
|
591
|
+
* @example
|
|
592
|
+
* ```ts
|
|
593
|
+
* isCompletable(createWorkflow({ id: 'w', name: 'W', phases: [] })) // true
|
|
594
|
+
* ```
|
|
595
|
+
*/
|
|
596
|
+
function isCompletable(workflow) {
|
|
597
|
+
return workflow.status === "pending";
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Tests whether a task attempt is being genuinely cancelled rather than merely timed out.
|
|
601
|
+
*
|
|
602
|
+
* @remarks
|
|
603
|
+
* The discriminator that keeps a per-attempt deadline off the skip path. Three causes fire a
|
|
604
|
+
* running task's folded signal, and only two of them mean "skip this task": the task's own
|
|
605
|
+
* `signal` (its `stop` / `skip`), and the unit or run signal (a sibling fail-fast under
|
|
606
|
+
* `bail: true`, or a run-level abort / timeout / budget / `destroy`). A bare per-attempt timeout
|
|
607
|
+
* fires neither — it aborts only the deadline portion of the attempt signal — so it stays a
|
|
608
|
+
* retryable failure of that attempt instead of skipping the leaf and losing the recorded fault.
|
|
609
|
+
* Read fresh at each call so a cancel that lands mid-dispatch is seen.
|
|
610
|
+
*
|
|
611
|
+
* @param task - The live task the attempt is driving
|
|
612
|
+
* @param controller - The substrate unit handle carrying the unit-level abort
|
|
613
|
+
* @param runSignal - The run's folded cancellation signal
|
|
614
|
+
* @returns True if the attempt is being genuinely cancelled; false otherwise
|
|
615
|
+
*
|
|
616
|
+
* @example
|
|
617
|
+
* ```ts
|
|
618
|
+
* isSkipping(task, controller, runSignal) // false until a cancel fires
|
|
619
|
+
* ```
|
|
620
|
+
*/
|
|
621
|
+
function isSkipping(task, controller, runSignal) {
|
|
622
|
+
return task.signal.aborted || controller.aborted || runSignal.aborted;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Tests whether one attempt still owns the task it launched.
|
|
626
|
+
*
|
|
627
|
+
* @remarks
|
|
628
|
+
* A retried task is re-dispatched while an earlier attempt's handler may still be resolving, so
|
|
629
|
+
* every settlement path re-checks ownership before touching the leaf. Ownership needs both
|
|
630
|
+
* halves: the run-local `owners` ledger must still name this attempt, and the live task's own
|
|
631
|
+
* `attempts` tally must still match it. A superseded attempt reads `false` and returns without
|
|
632
|
+
* recording anything, so a late resolution can never overwrite the newer attempt's outcome.
|
|
633
|
+
*
|
|
634
|
+
* @param owners - The run-local ledger of the attempt owning each task id
|
|
635
|
+
* @param task - The live task the attempt launched
|
|
636
|
+
* @param attempt - The one-based attempt number to test
|
|
637
|
+
* @returns True if `attempt` still owns `task`; false otherwise
|
|
638
|
+
*
|
|
639
|
+
* @example
|
|
640
|
+
* ```ts
|
|
641
|
+
* const owners = new Map([[task.id, 1]])
|
|
642
|
+
* ownsAttempt(owners, task, 1) // true while the task's own `attempts` is 1
|
|
643
|
+
* ownsAttempt(owners, task, 2) // false
|
|
644
|
+
* ```
|
|
645
|
+
*/
|
|
646
|
+
function ownsAttempt(owners, task, attempt) {
|
|
647
|
+
return owners.get(task.id) === attempt && task.attempts === attempt;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Derives a phase's status from its tasks' statuses, the most severe terminal status winning
|
|
651
|
+
* (tasks are concurrent, so this is an order-insensitive reduction).
|
|
367
652
|
*
|
|
368
653
|
* @remarks
|
|
369
654
|
* The truth table (most-severe terminal wins; `bail`-agnostic — a phase surfaces a
|
|
370
655
|
* task failure as `failed` so the workflow's `bail` policy can decide):
|
|
371
656
|
* - no tasks ⇒ `pending`.
|
|
372
|
-
* - any task `running`,
|
|
657
|
+
* - any task `running`, or a mix of started-and-unsettled tasks (some non-`pending`
|
|
373
658
|
* but not all terminal) ⇒ `running`.
|
|
374
659
|
* - every task `pending` ⇒ `pending`.
|
|
375
660
|
* - all terminal: any `failed` ⇒ `failed`; else any `stopped` ⇒ `stopped`; else any
|
|
@@ -380,7 +665,7 @@ function isTerminalStatus(status) {
|
|
|
380
665
|
* task makes the phase `failed`.
|
|
381
666
|
*
|
|
382
667
|
* @param tasks - The phase's task statuses, in any order
|
|
383
|
-
* @returns The derived {@link
|
|
668
|
+
* @returns The derived phase {@link LifecycleStatus}
|
|
384
669
|
*/
|
|
385
670
|
function derivePhaseStatus(tasks) {
|
|
386
671
|
if (tasks.length === 0) return "pending";
|
|
@@ -392,24 +677,25 @@ function derivePhaseStatus(tasks) {
|
|
|
392
677
|
return "skipped";
|
|
393
678
|
}
|
|
394
679
|
/**
|
|
395
|
-
*
|
|
396
|
-
* paired with the
|
|
397
|
-
* failure outcome is
|
|
398
|
-
*
|
|
680
|
+
* Derives a workflow's status from its phases' {@link PhaseDerivation}s — each phase's status
|
|
681
|
+
* paired with the effective `bail` it ran under (`phase.bail ?? workflow.bail`) — so the
|
|
682
|
+
* failure outcome is aware of each phase's own policy, and `failed` is reachable only where
|
|
683
|
+
* that policy is `true` (phases are sequential, but the derivation is an order-insensitive
|
|
684
|
+
* reduction over the settled set).
|
|
399
685
|
*
|
|
400
686
|
* @remarks
|
|
401
|
-
* `bail` is
|
|
402
|
-
* {@link PhaseDerivation} rather than passed as one scalar. It is the
|
|
687
|
+
* `bail` is a per-phase override, so it is carried on each
|
|
688
|
+
* {@link PhaseDerivation} rather than passed as one scalar. It is the only axis that changes
|
|
403
689
|
* the failure outcome, decided per phase:
|
|
404
690
|
* - **A `failed` phase whose effective `bail` is `true` (halt)** propagates ⇒ the workflow is
|
|
405
691
|
* `failed` (the database-transaction halt) — even when the workflow default is graceful.
|
|
406
|
-
* - **A `failed` phase whose effective `bail` is `false` (graceful)** is
|
|
407
|
-
* failure — it folds into completion like a settled phase. A graceful failed phase
|
|
692
|
+
* - **A `failed` phase whose effective `bail` is `false` (graceful)** is data, not a workflow
|
|
693
|
+
* failure — it folds into completion like a settled phase. A graceful failed phase never
|
|
408
694
|
* makes the workflow `failed` — even when the workflow default is strict.
|
|
409
695
|
*
|
|
410
696
|
* The rest of the table is shared:
|
|
411
697
|
* - no phases ⇒ `pending`.
|
|
412
|
-
* - any phase `running`,
|
|
698
|
+
* - any phase `running`, or a mix of started-and-unsettled phases (some non-`pending`
|
|
413
699
|
* but not all terminal) ⇒ `running`.
|
|
414
700
|
* - every phase `pending` ⇒ `pending`.
|
|
415
701
|
* - all terminal (a `failed` phase counts as terminal here): any `stopped` ⇒ `stopped`; else
|
|
@@ -417,7 +703,7 @@ function derivePhaseStatus(tasks) {
|
|
|
417
703
|
* else (all `skipped`) ⇒ `skipped`.
|
|
418
704
|
*
|
|
419
705
|
* @param phases - The workflow's per-phase {@link PhaseDerivation}s (status + effective bail), in any order
|
|
420
|
-
* @returns The derived {@link
|
|
706
|
+
* @returns The derived workflow {@link LifecycleStatus}
|
|
421
707
|
*/
|
|
422
708
|
function deriveWorkflowStatus(phases) {
|
|
423
709
|
if (phases.length === 0) return "pending";
|
|
@@ -429,18 +715,19 @@ function deriveWorkflowStatus(phases) {
|
|
|
429
715
|
return "skipped";
|
|
430
716
|
}
|
|
431
717
|
/**
|
|
432
|
-
*
|
|
433
|
-
* the index of the first entry in the contiguous trailing run of `pending` entries
|
|
718
|
+
* Derives the pending-suffix boundary of a positional list of {@link LifecycleStatus}es —
|
|
719
|
+
* the index of the first entry in the contiguous trailing run of `pending` entries, or the
|
|
720
|
+
* list's length where it has none.
|
|
434
721
|
*
|
|
435
722
|
* @remarks
|
|
436
|
-
* The native, hook-free replacement for a runner-installed cursor
|
|
723
|
+
* The native, hook-free replacement for a runner-installed cursor: a
|
|
437
724
|
* {@link import('./types.js').WorkflowInterface}'s `add` / `remove` / `move` / `update`
|
|
438
725
|
* reads this over its live phases' statuses to decide which positions are safe to edit.
|
|
439
|
-
* Because entries run
|
|
440
|
-
* already-started entry forms a contiguous
|
|
441
|
-
* entry forms the trailing suffix — so the boundary is
|
|
726
|
+
* Because entries run sequentially (phases sequential, AGENTS determinism), every
|
|
727
|
+
* already-started entry forms a contiguous leading prefix and every still-`pending`
|
|
728
|
+
* entry forms the trailing suffix — so the boundary is the count of leading
|
|
442
729
|
* non-`pending` entries: the index of the first `pending` entry, or the full length when
|
|
443
|
-
* none is `pending` (nothing is safely editable). A `pending` container's entries are
|
|
730
|
+
* none is `pending` (nothing is safely editable). A `pending` container's entries are all
|
|
444
731
|
* `pending`, so the boundary is `0` and every position is naturally accepted — callers
|
|
445
732
|
* need no special case for that.
|
|
446
733
|
*
|
|
@@ -459,8 +746,8 @@ function deriveBoundary(statuses) {
|
|
|
459
746
|
return index === -1 ? statuses.length : index;
|
|
460
747
|
}
|
|
461
748
|
/**
|
|
462
|
-
*
|
|
463
|
-
* {@link
|
|
749
|
+
* Tests whether the live W-b task state machine may move directly from one
|
|
750
|
+
* {@link LifecycleStatus} to another — the legal-transition guard.
|
|
464
751
|
*
|
|
465
752
|
* @remarks
|
|
466
753
|
* Reads the {@link import('./constants.js').TASK_TRANSITIONS} graph: `true` only when
|
|
@@ -470,13 +757,14 @@ function deriveBoundary(statuses) {
|
|
|
470
757
|
*
|
|
471
758
|
* @param from - The task's current status
|
|
472
759
|
* @param to - The status the transition would move it to
|
|
473
|
-
* @returns
|
|
760
|
+
* @returns True if the move is legal; false otherwise
|
|
474
761
|
*/
|
|
475
762
|
function canTransitionTask(from, to) {
|
|
476
763
|
return TASK_TRANSITIONS[from].includes(to);
|
|
477
764
|
}
|
|
478
765
|
/**
|
|
479
|
-
*
|
|
766
|
+
* Resolves a task's runtime silence window against its workflow default, to a host-safe
|
|
767
|
+
* `1..MAX_TIMER_MS` window or to `undefined` where the task disables it.
|
|
480
768
|
*
|
|
481
769
|
* @param value - The task-level override; any present non-positive or non-finite value disables
|
|
482
770
|
* @param fallback - The workflow-level default
|
|
@@ -487,7 +775,7 @@ function resolveTaskSilence(value, fallback) {
|
|
|
487
775
|
return fallback !== void 0 && Number.isFinite(fallback) && fallback > 0 && fallback <= 2147483647 ? fallback : void 0;
|
|
488
776
|
}
|
|
489
777
|
/**
|
|
490
|
-
*
|
|
778
|
+
* Boxes a value as a {@link Success} — the graceful outcome half of a {@link Result}.
|
|
491
779
|
*
|
|
492
780
|
* @typeParam T - The boxed value's type
|
|
493
781
|
* @param value - The value to box
|
|
@@ -505,7 +793,7 @@ function success(value) {
|
|
|
505
793
|
};
|
|
506
794
|
}
|
|
507
795
|
/**
|
|
508
|
-
*
|
|
796
|
+
* Boxes an error as a {@link Failure} — the graceful outcome half of a {@link Result}.
|
|
509
797
|
*
|
|
510
798
|
* @typeParam E - The boxed error's type
|
|
511
799
|
* @param error - The error to box
|
|
@@ -523,7 +811,7 @@ function failure(error) {
|
|
|
523
811
|
};
|
|
524
812
|
}
|
|
525
813
|
/**
|
|
526
|
-
*
|
|
814
|
+
* Normalizes an unknown thrown value to a non-empty persistence-safe message.
|
|
527
815
|
*
|
|
528
816
|
* @param error - The caught value
|
|
529
817
|
* @returns A non-empty message without stack or cause data
|
|
@@ -537,16 +825,16 @@ function errorToMessage(error) {
|
|
|
537
825
|
}
|
|
538
826
|
}
|
|
539
827
|
/**
|
|
540
|
-
*
|
|
828
|
+
* Finds the first {@link TaskResult} in a positional list whose boxed outcome is a
|
|
541
829
|
* `Failure` — the pure scan shared by a phase's and a workflow's derived-`failed`
|
|
542
830
|
* `fail`-event lookup.
|
|
543
831
|
*
|
|
544
832
|
* @remarks
|
|
545
833
|
* The shared leaf behind {@link import('./phases/Phase.js').Phase} and
|
|
546
|
-
* {@link import('./Workflow.js').Workflow}'s own `#failure` — each gathers
|
|
834
|
+
* {@link import('./Workflow.js').Workflow}'s own `#failure` — each gathers its tier's
|
|
547
835
|
* results (a phase's own settled tasks, a workflow's flattened `results()`) and feeds
|
|
548
|
-
* them here; the tier-local method keeps the
|
|
549
|
-
* status
|
|
836
|
+
* them here; the tier-local method keeps the invariant throw (a derived `failed`
|
|
837
|
+
* status means a failing result exists) because throwing on `undefined` is
|
|
550
838
|
* orchestration, not a leaf concern.
|
|
551
839
|
*
|
|
552
840
|
* @param results - The results to scan, in any order
|
|
@@ -561,7 +849,7 @@ function findFailure(results) {
|
|
|
561
849
|
return results.find((result) => result.result?.success === false);
|
|
562
850
|
}
|
|
563
851
|
/**
|
|
564
|
-
*
|
|
852
|
+
* Builds a {@link WorkflowContext} — the identity every level inherits — from a node's
|
|
565
853
|
* `id` / `name` / optional `description`.
|
|
566
854
|
*
|
|
567
855
|
* @remarks
|
|
@@ -580,10 +868,10 @@ function buildWorkflowContext(node) {
|
|
|
580
868
|
});
|
|
581
869
|
}
|
|
582
870
|
/**
|
|
583
|
-
*
|
|
871
|
+
* Builds a {@link PhaseContext} — a phase's own identity plus a back-reference to its
|
|
584
872
|
* workflow — from the parent {@link WorkflowContext} and the phase node's identity.
|
|
585
873
|
*
|
|
586
|
-
* @param workflow - The parent workflow context (the lineage pointer
|
|
874
|
+
* @param workflow - The parent workflow context (the lineage pointer up the tree)
|
|
587
875
|
* @param node - The phase's identity (`id` / `name` / optional `description`)
|
|
588
876
|
* @returns The {@link PhaseContext}
|
|
589
877
|
*/
|
|
@@ -594,11 +882,11 @@ function buildPhaseContext(workflow, node) {
|
|
|
594
882
|
});
|
|
595
883
|
}
|
|
596
884
|
/**
|
|
597
|
-
*
|
|
885
|
+
* Builds a {@link TaskContext} — a task's own identity plus a back-reference to its phase
|
|
598
886
|
* (and, transitively, its workflow) — from the parent {@link PhaseContext} and the task
|
|
599
887
|
* node's identity.
|
|
600
888
|
*
|
|
601
|
-
* @param phase - The parent phase context (carrying the full lineage
|
|
889
|
+
* @param phase - The parent phase context (carrying the full lineage up the tree)
|
|
602
890
|
* @param node - The task's identity (`id` / `name` / optional `description`)
|
|
603
891
|
* @returns The {@link TaskContext}
|
|
604
892
|
*/
|
|
@@ -609,31 +897,31 @@ function buildTaskContext(phase, node) {
|
|
|
609
897
|
});
|
|
610
898
|
}
|
|
611
899
|
/**
|
|
612
|
-
*
|
|
613
|
-
* node `pending`, no results, empty metadata — so the live W-b tree has
|
|
614
|
-
* path
|
|
900
|
+
* Converts a {@link WorkflowDefinition} into an initial {@link WorkflowSnapshot} — every
|
|
901
|
+
* node `pending`, no results, empty metadata — so the live W-b tree has one construction
|
|
902
|
+
* path, snapshot-driven, for a fresh build and for a restore alike.
|
|
615
903
|
*
|
|
616
904
|
* @remarks
|
|
617
905
|
* The structural fields (`id` / `name` / `description` + the ordered phases / tasks)
|
|
618
906
|
* carry over verbatim, as does each phase's `concurrency` (persisted on the
|
|
619
|
-
* {@link PhaseSnapshot} so a restore reinstates the same throttle) and each task's `
|
|
907
|
+
* {@link PhaseSnapshot} so a restore reinstates the same throttle) and each task's `behavior` /
|
|
620
908
|
* `retries` / `timeout` (persisted on the {@link TaskSnapshot}, like `bail` / `concurrency`,
|
|
621
909
|
* so a restore + a {@link import('./types.js').WorkflowOptions.functions} registry resumes
|
|
622
910
|
* real work). The `bail` policy carries over — at the
|
|
623
|
-
* workflow tier
|
|
624
|
-
*
|
|
625
|
-
* snapshot is self-contained; a fresh seed has no `override`. `created` / `updated` are stamped
|
|
911
|
+
* workflow tier and, per phase, the
|
|
912
|
+
* effective policy (`phase.bail ?? workflowBail`) on each {@link PhaseSnapshot} — so the seeded
|
|
913
|
+
* snapshot is self-contained; a fresh seed has no `override`. `created` / `updated` are stamped at that point.
|
|
626
914
|
* {@link import('./factories.js').createWorkflow} builds from this.
|
|
627
915
|
*
|
|
628
|
-
* The optional `bail` override is the
|
|
916
|
+
* The optional `bail` override is the effective workflow policy the tree will run under
|
|
629
917
|
* (`createWorkflow` / the runner resolve `options.bail ?? definition.bail ?? DEFAULT_BAIL` and
|
|
630
|
-
* pass it here), so an `options.bail` override reaches
|
|
918
|
+
* pass it here), so an `options.bail` override reaches both the workflow tier and the
|
|
631
919
|
* inheritance default of every phase that declares no `bail` of its own — otherwise the
|
|
632
920
|
* per-phase seeds would silently ignore the override. Omitted ⇒ the definition's own `bail`
|
|
633
921
|
* (defaulting to the graceful {@link import('./constants.js').DEFAULT_BAIL}).
|
|
634
922
|
*
|
|
635
923
|
* @param definition - The workflow definition to seed from
|
|
636
|
-
* @param bail - The
|
|
924
|
+
* @param bail - The effective workflow bail to seed both tiers with (defaults to the definition's)
|
|
637
925
|
* @returns An initial, all-`pending` {@link WorkflowSnapshot}
|
|
638
926
|
*/
|
|
639
927
|
function definitionToSnapshot(definition, bail) {
|
|
@@ -651,11 +939,11 @@ function definitionToSnapshot(definition, bail) {
|
|
|
651
939
|
};
|
|
652
940
|
}
|
|
653
941
|
/**
|
|
654
|
-
*
|
|
942
|
+
* Converts one {@link import('./types.js').PhaseDefinition} into an initial, all-`pending`
|
|
655
943
|
* {@link PhaseSnapshot} — the per-phase step of {@link definitionToSnapshot}.
|
|
656
944
|
*
|
|
657
945
|
* @remarks
|
|
658
|
-
* The snapshot persists the
|
|
946
|
+
* The snapshot persists the effective failure policy this phase runs under: the phase's own
|
|
659
947
|
* `bail` when it declares one, else the `workflowBail` it inherits — so a restore reinstates
|
|
660
948
|
* the same per-phase policy without a silent default (`effectiveBail = phase.bail ?? workflowBail`).
|
|
661
949
|
* `concurrency` (the resource throttle) carries over verbatim, omitted when undefined.
|
|
@@ -676,14 +964,14 @@ function phaseDefinitionToSnapshot(phase, workflowBail) {
|
|
|
676
964
|
};
|
|
677
965
|
}
|
|
678
966
|
/**
|
|
679
|
-
*
|
|
967
|
+
* Converts one {@link import('./types.js').TaskDefinition} into an initial, `pending`
|
|
680
968
|
* {@link TaskSnapshot} — the per-task leaf step of {@link definitionToSnapshot} (no
|
|
681
969
|
* result yet, empty metadata).
|
|
682
970
|
*
|
|
683
971
|
* @remarks
|
|
684
|
-
* `
|
|
972
|
+
* `behavior` / `retries` / `timeout` carry over verbatim (persisted declarative config, like a
|
|
685
973
|
* phase's `bail` / `concurrency`) — a restore reinstates the same behavior reference and
|
|
686
|
-
* reliability overrides
|
|
974
|
+
* reliability overrides after pairing with a {@link import('./types.js').WorkflowOptions.functions}
|
|
687
975
|
* registry.
|
|
688
976
|
*
|
|
689
977
|
* @param task - The task definition to seed from
|
|
@@ -697,13 +985,13 @@ function taskDefinitionToSnapshot(task) {
|
|
|
697
985
|
status: "pending",
|
|
698
986
|
metadata: {},
|
|
699
987
|
attempts: 0,
|
|
700
|
-
...task.
|
|
988
|
+
...task.behavior === void 0 ? {} : { behavior: task.behavior },
|
|
701
989
|
...task.retries === void 0 ? {} : { retries: task.retries },
|
|
702
990
|
...task.timeout === void 0 ? {} : { timeout: task.timeout }
|
|
703
991
|
};
|
|
704
992
|
}
|
|
705
993
|
/**
|
|
706
|
-
*
|
|
994
|
+
* Converts interrupted running work into a recoverable pending suffix or an
|
|
707
995
|
* exhausted recovery failure without replenishing attempts.
|
|
708
996
|
*
|
|
709
997
|
* @param snapshot - A fully validated owned snapshot with no terminal overrides
|
|
@@ -780,11 +1068,58 @@ function recoverWorkflowSnapshot(snapshot) {
|
|
|
780
1068
|
updated: now
|
|
781
1069
|
};
|
|
782
1070
|
}
|
|
783
|
-
/**
|
|
1071
|
+
/**
|
|
1072
|
+
* Compares two optional description values.
|
|
1073
|
+
*
|
|
1074
|
+
* @remarks
|
|
1075
|
+
* The equality rule a lineage check needs: two descriptions match when they are the same value
|
|
1076
|
+
* and that value is either a string or genuine absence. Anything else — a number, an object, a
|
|
1077
|
+
* `null` — never matches, even against itself, so a lineage stamped with a non-string description
|
|
1078
|
+
* is rejected rather than silently accepted.
|
|
1079
|
+
*
|
|
1080
|
+
* @param left - The first description value
|
|
1081
|
+
* @param right - The second description value
|
|
1082
|
+
* @returns True if both are the same string or both absent; false otherwise
|
|
1083
|
+
*
|
|
1084
|
+
* @example
|
|
1085
|
+
* ```ts
|
|
1086
|
+
* matchesDescription('build', 'build') // true
|
|
1087
|
+
* matchesDescription(undefined, undefined) // true
|
|
1088
|
+
* matchesDescription('build', undefined) // false
|
|
1089
|
+
* ```
|
|
1090
|
+
*/
|
|
784
1091
|
function matchesDescription(left, right) {
|
|
785
1092
|
return left === right && (left === void 0 || typeof left === "string");
|
|
786
1093
|
}
|
|
787
|
-
/**
|
|
1094
|
+
/**
|
|
1095
|
+
* Tests a result's lineage against its containing snapshot nodes.
|
|
1096
|
+
*
|
|
1097
|
+
* @remarks
|
|
1098
|
+
* The four arguments are the result and the three snapshot nodes it claims to belong to, read
|
|
1099
|
+
* from the outside in: a {@link TaskResult} is self-describing, so restoring one is only safe
|
|
1100
|
+
* when every identity it carries agrees with the tree it was found in. It checks the exact key
|
|
1101
|
+
* set at each level, that `status` equals the owning task's, and that the `task` / `phase` /
|
|
1102
|
+
* `workflow` contexts — including the nested `task.phase.workflow` lineage — carry the same `id`,
|
|
1103
|
+
* `name`, and `description` as the nodes containing them. It then requires the boxed outcome to
|
|
1104
|
+
* match the status: a `Success` holding JSON for `completed`, a `Failure` holding a
|
|
1105
|
+
* {@link TaskFailure} for `failed`, and nothing for any other status. Total — a hostile
|
|
1106
|
+
* prototype, accessor, or cycle answers `false` rather than throwing.
|
|
1107
|
+
*
|
|
1108
|
+
* @param value - The candidate {@link TaskResult}
|
|
1109
|
+
* @param workflow - The workflow snapshot node containing it
|
|
1110
|
+
* @param phase - The phase snapshot node containing it
|
|
1111
|
+
* @param task - The task snapshot node the result belongs to
|
|
1112
|
+
* @returns True if `value` is a {@link TaskResult} whose lineage and outcome match
|
|
1113
|
+
* those nodes; false otherwise
|
|
1114
|
+
*
|
|
1115
|
+
* @example
|
|
1116
|
+
* ```ts
|
|
1117
|
+
* const snapshot = workflow.snapshot()
|
|
1118
|
+
* const phase = snapshot.phases[0]
|
|
1119
|
+
* const task = phase?.tasks[0]
|
|
1120
|
+
* isTaskResult(task?.result, snapshot, phase, task) // true for a settled task
|
|
1121
|
+
* ```
|
|
1122
|
+
*/
|
|
788
1123
|
function isTaskResult(value, workflow, phase, task) {
|
|
789
1124
|
try {
|
|
790
1125
|
if (!isRecord(value) || !isRecord(workflow) || !isRecord(phase) || !isRecord(task) || !Object.keys(value).every((key) => key === "task" || key === "phase" || key === "workflow" || key === "status" || key === "result" || key === "timestamp") || !isLifecycleStatus(value.status) || value.status !== task.status || !isFiniteNumber(value.timestamp) || value.timestamp < 0 || !isRecord(value.task) || !isRecord(value.phase) || !isRecord(value.workflow) || !Object.keys(value.workflow).every((key) => key === "id" || key === "name" || key === "description") || !Object.keys(value.phase).every((key) => key === "id" || key === "name" || key === "description" || key === "workflow") || !Object.keys(value.task).every((key) => key === "id" || key === "name" || key === "description" || key === "phase")) return false;
|
|
@@ -800,19 +1135,30 @@ function isTaskResult(value, workflow, phase, task) {
|
|
|
800
1135
|
}
|
|
801
1136
|
function hasWorkflowHandlers(workflow, functions) {
|
|
802
1137
|
if ("destroyed" in workflow) {
|
|
803
|
-
for (const phase of workflow.phases.phases()) for (const task of phase.tasks.tasks()) if (task.
|
|
1138
|
+
for (const phase of workflow.phases.phases()) for (const task of phase.tasks.tasks()) if (task.behavior !== void 0 && !isFunction(task.handler)) return false;
|
|
804
1139
|
return true;
|
|
805
1140
|
}
|
|
806
|
-
const
|
|
1141
|
+
const behaviors = /* @__PURE__ */ new Set();
|
|
807
1142
|
for (const phase of workflow.phases) for (const task of phase.tasks) {
|
|
808
|
-
if (task.
|
|
809
|
-
|
|
810
|
-
if (!isFunction(functions?.[task.
|
|
1143
|
+
if (task.behavior === void 0 || behaviors.has(task.behavior)) continue;
|
|
1144
|
+
behaviors.add(task.behavior);
|
|
1145
|
+
if (!isFunction(functions?.[task.behavior])) return false;
|
|
811
1146
|
}
|
|
812
1147
|
return true;
|
|
813
1148
|
}
|
|
814
|
-
/**
|
|
815
|
-
|
|
1149
|
+
/**
|
|
1150
|
+
* Locates the nearest identifiable node for an inconsistent owned snapshot.
|
|
1151
|
+
*
|
|
1152
|
+
* @remarks
|
|
1153
|
+
* The walk stops at the first phase or task whose persisted fields are inconsistent and returns
|
|
1154
|
+
* the identifiers it could read there, so a diagnostic can name the offending node even when part
|
|
1155
|
+
* of its identity is unreadable.
|
|
1156
|
+
*
|
|
1157
|
+
* @param value - The candidate snapshot, which may be any unknown value
|
|
1158
|
+
* @returns The nearest identifying record naming the offending `phase` and `task`, or `undefined`
|
|
1159
|
+
* when no inconsistent node is identifiable
|
|
1160
|
+
*/
|
|
1161
|
+
function scanSnapshotContext(value) {
|
|
816
1162
|
if (!isRecord(value) || !isArray(value.phases)) return void 0;
|
|
817
1163
|
for (const phase of value.phases) {
|
|
818
1164
|
if (!isRecord(phase)) continue;
|
|
@@ -820,7 +1166,7 @@ function workflowSnapshotContext(value) {
|
|
|
820
1166
|
if (!isBoolean(phase.bail) || phase.concurrency !== void 0 && (!isInteger(phase.concurrency) || phase.concurrency < 1) || !isArray(phase.tasks)) return phaseContext;
|
|
821
1167
|
for (const task of phase.tasks) {
|
|
822
1168
|
if (!isRecord(task)) continue;
|
|
823
|
-
if (task.
|
|
1169
|
+
if (task.behavior !== void 0 && !isNonEmptyString(task.behavior) || task.retries !== void 0 && (!isInteger(task.retries) || task.retries < 0) || task.timeout !== void 0 && (!isInteger(task.timeout) || task.timeout < 0 || task.timeout > 2147483647) || !isInteger(task.attempts) || task.attempts < 0) return {
|
|
824
1170
|
...phaseContext ?? {},
|
|
825
1171
|
...isNonEmptyString(task.id) ? { task: task.id } : {}
|
|
826
1172
|
};
|
|
@@ -828,7 +1174,7 @@ function workflowSnapshotContext(value) {
|
|
|
828
1174
|
}
|
|
829
1175
|
}
|
|
830
1176
|
/**
|
|
831
|
-
*
|
|
1177
|
+
* Flattens a nested list of per-phase {@link TaskResult} lists into one positional list
|
|
832
1178
|
* — the workflow tier of the result tree, built from each phase's `results()`.
|
|
833
1179
|
*
|
|
834
1180
|
* @remarks
|
|
@@ -843,16 +1189,15 @@ function collectResults(phases) {
|
|
|
843
1189
|
return phases.flat();
|
|
844
1190
|
}
|
|
845
1191
|
/**
|
|
846
|
-
*
|
|
1192
|
+
* Inserts one `[key, value]` entry at a positional index into a readonly entries array —
|
|
847
1193
|
* the pure splice-in step behind an insertion-ordered registry's `add`.
|
|
848
1194
|
*
|
|
849
1195
|
* @remarks
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
* mutate `entries`; returns a new array.
|
|
1196
|
+
* Used by the shared {@link import('./Collection.js').Collection} store both managers hold: it
|
|
1197
|
+
* converts its insertion-ordered `Map` to `[...map.entries()]`, calls this to splice the new entry
|
|
1198
|
+
* in at the target index, then rebuilds the `Map` from the result (a stateful step that stays a
|
|
1199
|
+
* `#` private method — this helper does no `Map` construction). Does not mutate `entries`;
|
|
1200
|
+
* returns a new array.
|
|
856
1201
|
*
|
|
857
1202
|
* @typeParam T - The entry's value type
|
|
858
1203
|
* @param entries - The current positional entries, in order
|
|
@@ -872,16 +1217,17 @@ function insertEntry(entries, index, key, value) {
|
|
|
872
1217
|
return next;
|
|
873
1218
|
}
|
|
874
1219
|
/**
|
|
875
|
-
*
|
|
1220
|
+
* Repositions the entry keyed `key` to a new positional index in a readonly entries
|
|
876
1221
|
* array — the pure remove-then-reinsert step behind an insertion-ordered registry's
|
|
877
1222
|
* `move`.
|
|
878
1223
|
*
|
|
879
1224
|
* @remarks
|
|
880
1225
|
* The move counterpart of {@link insertEntry}: finds the entry by `key`, splices it
|
|
881
1226
|
* out, then splices it back in at `index`. An absent `key` is a no-op (returns a copy
|
|
882
|
-
* of `entries` unchanged) — the caller
|
|
883
|
-
* already gates on the target's
|
|
884
|
-
*
|
|
1227
|
+
* of `entries` unchanged) — the caller, the shared
|
|
1228
|
+
* {@link import('./Collection.js').Collection} store's `move`, already gates on the target's
|
|
1229
|
+
* existence before calling this, so the no-op branch is defensive, never reached in practice.
|
|
1230
|
+
* Does not mutate `entries`; returns a new array.
|
|
885
1231
|
*
|
|
886
1232
|
* @typeParam T - The entry's value type
|
|
887
1233
|
* @param entries - The current positional entries, in order
|
|
@@ -903,17 +1249,7 @@ function moveEntry(entries, key, index) {
|
|
|
903
1249
|
return next;
|
|
904
1250
|
}
|
|
905
1251
|
/**
|
|
906
|
-
*
|
|
907
|
-
* externally, so a caller can resolve/reject it from outside the executor.
|
|
908
|
-
*
|
|
909
|
-
* @typeParam T - The value the deferred promise resolves
|
|
910
|
-
* @returns A deferred `promise` plus its `resolve` / `reject`
|
|
911
|
-
*/
|
|
912
|
-
function createDeferred() {
|
|
913
|
-
return Promise.withResolvers();
|
|
914
|
-
}
|
|
915
|
-
/**
|
|
916
|
-
* Schedule one cancellable host operation behind an owned settlement signal.
|
|
1252
|
+
* Schedules one cancellable host operation behind an owned settlement signal.
|
|
917
1253
|
*
|
|
918
1254
|
* @remarks
|
|
919
1255
|
* A defined `signal` that is not a native `AbortSignal` is refused before anything is armed, as a
|
|
@@ -980,17 +1316,46 @@ function scheduleHost(start, signal) {
|
|
|
980
1316
|
});
|
|
981
1317
|
}
|
|
982
1318
|
/**
|
|
983
|
-
*
|
|
984
|
-
* busy-loop, that NEVER rejects.
|
|
1319
|
+
* Schedules the shared host timer boundary every scheduler backend resumes from.
|
|
985
1320
|
*
|
|
986
1321
|
* @remarks
|
|
987
|
-
*
|
|
1322
|
+
* The one `setTimeout` / `clearTimeout` boundary in the package: the cross-environment
|
|
1323
|
+
* {@link import('./Scheduler.js').Scheduler}, both Node primitives, and every browser backend's
|
|
1324
|
+
* `delay` and macrotask fallback route here, so the timer is armed and cleared in one place. It
|
|
1325
|
+
* composes {@link scheduleHost}, which owns listener safety, the cancellation race, the exact
|
|
1326
|
+
* caller reason, and once-only settlement. It does not validate `ms`: the value passes straight to
|
|
1327
|
+
* the host `setTimeout`, which clamps a negative value or `NaN` to about zero, so an
|
|
1328
|
+
* out-of-domain `ms` resumes on the next host turn rather than throwing. Pass a non-negative
|
|
1329
|
+
* finite `ms`.
|
|
1330
|
+
*
|
|
1331
|
+
* @param ms - The milliseconds to wait before resuming
|
|
1332
|
+
* @param signal - Optional caller cancellation signal
|
|
1333
|
+
* @returns A promise that resolves after `ms`, or rejects with the caller's exact abort reason
|
|
1334
|
+
*
|
|
1335
|
+
* @example
|
|
1336
|
+
* ```ts
|
|
1337
|
+
* const controller = new AbortController()
|
|
1338
|
+
* await delayHost(0, controller.signal) // a real macrotask host turn
|
|
1339
|
+
* ```
|
|
1340
|
+
*/
|
|
1341
|
+
function delayHost(ms, signal) {
|
|
1342
|
+
return scheduleHost((complete) => {
|
|
1343
|
+
const handle = setTimeout(complete, ms);
|
|
1344
|
+
return () => clearTimeout(handle);
|
|
1345
|
+
}, signal);
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Parks until `signal` aborts — a promise-parked wait, never a timer or
|
|
1349
|
+
* busy-loop, that resolves on the abort event and never rejects.
|
|
1350
|
+
*
|
|
1351
|
+
* @remarks
|
|
1352
|
+
* Resolves immediately when `signal` is already aborted; otherwise attaches a one-shot
|
|
988
1353
|
* `abort` listener and resolves when it fires, removing the listener either way. The
|
|
989
1354
|
* shared leaf behind the duplicate abort-wiring an execution engine otherwise hand-rolls
|
|
990
1355
|
* at every fold point.
|
|
991
1356
|
*
|
|
992
1357
|
* @param signal - The signal to park on
|
|
993
|
-
* @returns A promise that resolves
|
|
1358
|
+
* @returns A promise that resolves after `signal` has aborted
|
|
994
1359
|
*
|
|
995
1360
|
* @example
|
|
996
1361
|
* ```ts
|
|
@@ -1007,25 +1372,125 @@ function parkSignal(signal) {
|
|
|
1007
1372
|
});
|
|
1008
1373
|
}
|
|
1009
1374
|
//#endregion
|
|
1375
|
+
//#region src/core/Collection.ts
|
|
1376
|
+
/**
|
|
1377
|
+
* Implements the insertion-ordered gated store both lean managers hold — entities keyed by `id`,
|
|
1378
|
+
* positional order preserved across an interior `skip` or `remove`.
|
|
1379
|
+
*
|
|
1380
|
+
* @remarks
|
|
1381
|
+
* - **One engine, two managers.** {@link import('./tasks/TaskManager.js').TaskManager} and
|
|
1382
|
+
* {@link import('./phases/PhaseManager.js').PhaseManager} differ only in the entity noun and the
|
|
1383
|
+
* patch shape they validate, so both hold one of these and add only their domain accessors
|
|
1384
|
+
* (`task` / `tasks`, `phase` / `phases`). The `Map`'s insertion order is the single source of
|
|
1385
|
+
* positional truth; `add` and `move` rebuild it through the pure
|
|
1386
|
+
* {@link import('./helpers.js').insertEntry} / {@link import('./helpers.js').moveEntry} leaves.
|
|
1387
|
+
* - **Gated mutation API.** `append` is the build-time wiring path and throws on a
|
|
1388
|
+
* duplicate id; `add` / `remove` / `move` / `update` return a graceful `MUTATION`
|
|
1389
|
+
* {@link WorkflowError} failure instead. Gating reads only the target's own existence, `pending`
|
|
1390
|
+
* status, id, and bounds — a container's own status is the owning entity's gate, applied before
|
|
1391
|
+
* it delegates here.
|
|
1392
|
+
* - **Event-free.** A purely structural container; the entity that owns it emits on success.
|
|
1393
|
+
*
|
|
1394
|
+
* @typeParam TEntry - The stored entity
|
|
1395
|
+
* @typeParam TPatch - The declarative partial update `update` validates and applies
|
|
1396
|
+
*
|
|
1397
|
+
* @example
|
|
1398
|
+
* ```ts
|
|
1399
|
+
* import { compileGuard } from '@orkestrel/contract'
|
|
1400
|
+
* import { Collection, taskUpdateShape } from '@orkestrel/workflow'
|
|
1401
|
+
* import type { TaskInterface, TaskUpdate } from '@orkestrel/workflow'
|
|
1402
|
+
*
|
|
1403
|
+
* const tasks = new Collection<TaskInterface, TaskUpdate>('task', compileGuard(taskUpdateShape))
|
|
1404
|
+
* tasks.append(task) // a live Task
|
|
1405
|
+
* tasks.entry(task.id) // the same task
|
|
1406
|
+
* tasks.entries() // [task]
|
|
1407
|
+
* tasks.count // 1
|
|
1408
|
+
* tasks.add(other, 0) // Result — inserted first
|
|
1409
|
+
* tasks.move(other.id, 1) // Result — repositioned
|
|
1410
|
+
* tasks.update(task.id, { name: 'Renamed task' }) // Result — patched
|
|
1411
|
+
* tasks.remove(other.id) // Result — dropped
|
|
1412
|
+
* ```
|
|
1413
|
+
*/
|
|
1414
|
+
var Collection = class {
|
|
1415
|
+
#entries = /* @__PURE__ */ new Map();
|
|
1416
|
+
#noun;
|
|
1417
|
+
#isPatch;
|
|
1418
|
+
constructor(noun, patch) {
|
|
1419
|
+
this.#noun = noun;
|
|
1420
|
+
this.#isPatch = patch;
|
|
1421
|
+
}
|
|
1422
|
+
get count() {
|
|
1423
|
+
return this.#entries.size;
|
|
1424
|
+
}
|
|
1425
|
+
append(entry) {
|
|
1426
|
+
if (this.#entries.has(entry.id)) throw new WorkflowError("MUTATION", `duplicate ${this.#noun} id '${entry.id}'`, { id: entry.id });
|
|
1427
|
+
this.#entries.set(entry.id, entry);
|
|
1428
|
+
}
|
|
1429
|
+
add(entry, index) {
|
|
1430
|
+
if (this.#entries.has(entry.id)) return failure(new WorkflowError("MUTATION", `duplicate ${this.#noun} id '${entry.id}'`, { id: entry.id }));
|
|
1431
|
+
const at = index ?? this.#entries.size;
|
|
1432
|
+
if (at < 0 || at > this.#entries.size) return failure(new WorkflowError("MUTATION", `index '${at}' out of bounds`, { index: at }));
|
|
1433
|
+
this.#reorder(insertEntry([...this.#entries.entries()], at, entry.id, entry));
|
|
1434
|
+
return success(entry);
|
|
1435
|
+
}
|
|
1436
|
+
remove(id) {
|
|
1437
|
+
const target = this.#pending(id);
|
|
1438
|
+
if (target === void 0) return this.#refuse(id);
|
|
1439
|
+
this.#entries.delete(id);
|
|
1440
|
+
return success(target);
|
|
1441
|
+
}
|
|
1442
|
+
move(id, index) {
|
|
1443
|
+
const target = this.#pending(id);
|
|
1444
|
+
if (target === void 0) return this.#refuse(id);
|
|
1445
|
+
if (index < 0 || index >= this.#entries.size) return failure(new WorkflowError("MUTATION", `index '${index}' out of bounds`, { index }));
|
|
1446
|
+
this.#reorder(moveEntry([...this.#entries.entries()], id, index));
|
|
1447
|
+
return success(target);
|
|
1448
|
+
}
|
|
1449
|
+
update(id, patch) {
|
|
1450
|
+
const target = this.#pending(id);
|
|
1451
|
+
if (target === void 0) return this.#refuse(id);
|
|
1452
|
+
if (!this.#isPatch(patch)) return failure(new WorkflowError("MUTATION", `invalid patch for ${this.#noun} '${id}'`, { id }));
|
|
1453
|
+
target.patch(patch);
|
|
1454
|
+
return success(target);
|
|
1455
|
+
}
|
|
1456
|
+
entry(id) {
|
|
1457
|
+
return this.#entries.get(id);
|
|
1458
|
+
}
|
|
1459
|
+
entries() {
|
|
1460
|
+
return [...this.#entries.values()];
|
|
1461
|
+
}
|
|
1462
|
+
#pending(id) {
|
|
1463
|
+
const target = this.#entries.get(id);
|
|
1464
|
+
return target === void 0 || target.status !== "pending" ? void 0 : target;
|
|
1465
|
+
}
|
|
1466
|
+
#refuse(id) {
|
|
1467
|
+
return failure(new WorkflowError("MUTATION", `${this.#noun} '${id}' is not a pending ${this.#noun}`, { id }));
|
|
1468
|
+
}
|
|
1469
|
+
#reorder(entries) {
|
|
1470
|
+
this.#entries.clear();
|
|
1471
|
+
for (const [key, value] of entries) this.#entries.set(key, value);
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
//#endregion
|
|
1010
1475
|
//#region src/core/Scheduler.ts
|
|
1011
1476
|
/**
|
|
1012
|
-
*
|
|
1477
|
+
* Implements the safe cross-environment cooperative-yield default — a {@link SchedulerInterface}
|
|
1013
1478
|
* built on `setTimeout` / `clearTimeout` alone, so it runs unchanged in both the
|
|
1014
1479
|
* browser and Node.
|
|
1015
1480
|
*
|
|
1016
1481
|
* @remarks
|
|
1017
|
-
* - **Cross-environment.** Uses
|
|
1482
|
+
* - **Cross-environment.** Uses only `setTimeout` / `clearTimeout` — universally
|
|
1018
1483
|
* available. It deliberately avoids env-specific fast paths (`setImmediate`,
|
|
1019
1484
|
* `scheduler.yield`, `requestAnimationFrame`, `node:timers/promises`,
|
|
1020
1485
|
* `MessageChannel`); those belong to the environment backends, built with the
|
|
1021
1486
|
* agent loop that consumes them.
|
|
1022
1487
|
* - **`yield` is a macrotask host-turn, not a microtask.** `yield()` waits on a
|
|
1023
|
-
* `setTimeout(0)`,
|
|
1488
|
+
* `setTimeout(0)`, not `queueMicrotask`. A microtask drains before the host
|
|
1024
1489
|
* regains control, so it would not actually let pending I/O, timers, or
|
|
1025
1490
|
* rendering run — it only defers within the current task. A zero-delay timer is
|
|
1026
1491
|
* the correct cross-environment "give the host a turn".
|
|
1027
1492
|
* - **Abort-aware.** A pending `yield` / `delay` rejects with `signal.reason` exactly.
|
|
1028
|
-
* {@link
|
|
1493
|
+
* {@link delayHost} links an owned settlement composite to the caller before arming
|
|
1029
1494
|
* the timer, so pre-abort schedules nothing, caller signal method mutation is harmless,
|
|
1030
1495
|
* cancellation clears the handle, and native first-settlement wins exactly once.
|
|
1031
1496
|
* - **Priority is accepted but uniform.** `options.priority` is part of the
|
|
@@ -1044,36 +1509,30 @@ function parkSignal(signal) {
|
|
|
1044
1509
|
*/
|
|
1045
1510
|
var Scheduler = class {
|
|
1046
1511
|
/**
|
|
1047
|
-
*
|
|
1048
|
-
* run, then
|
|
1512
|
+
* Yields control back to the host so other tasks (I/O, timers, rendering) can
|
|
1513
|
+
* run, then resumes — a macrotask turn through `setTimeout(0)` (not a microtask,
|
|
1049
1514
|
* which would resume before the host regains control).
|
|
1050
1515
|
*/
|
|
1051
1516
|
yield(options) {
|
|
1052
|
-
return
|
|
1517
|
+
return delayHost(0, options?.signal);
|
|
1053
1518
|
}
|
|
1054
1519
|
/**
|
|
1055
|
-
*
|
|
1520
|
+
* Resumes after at least `ms` milliseconds; abort rejects with `signal.reason`.
|
|
1056
1521
|
*
|
|
1057
1522
|
* @remarks
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1523
|
+
* Pass a non-negative finite `ms`. The primitive stays minimal and does no
|
|
1524
|
+
* validation: it passes `ms` straight to the host `setTimeout`, which clamps a
|
|
1525
|
+
* negative value or `NaN` to ~0 — so an out-of-domain `ms` resolves on the next
|
|
1526
|
+
* host turn rather than throwing.
|
|
1062
1527
|
*/
|
|
1063
1528
|
delay(ms, options) {
|
|
1064
|
-
return
|
|
1065
|
-
}
|
|
1066
|
-
#sleep(ms, signal) {
|
|
1067
|
-
return scheduleHost((complete) => {
|
|
1068
|
-
const handle = setTimeout(complete, ms);
|
|
1069
|
-
return () => clearTimeout(handle);
|
|
1070
|
-
}, signal);
|
|
1529
|
+
return delayHost(ms, options?.signal);
|
|
1071
1530
|
}
|
|
1072
1531
|
};
|
|
1073
1532
|
//#endregion
|
|
1074
1533
|
//#region src/core/cloners.ts
|
|
1075
1534
|
/**
|
|
1076
|
-
*
|
|
1535
|
+
* Validates and owns a workflow snapshot before live construction.
|
|
1077
1536
|
*
|
|
1078
1537
|
* @param input - The hostile snapshot boundary
|
|
1079
1538
|
* @param id - The optional storage key the owned snapshot must match
|
|
@@ -1089,7 +1548,7 @@ function cloneWorkflowSnapshot(input, id) {
|
|
|
1089
1548
|
if (isContractError(error)) throw new WorkflowError("RESTORE", `workflow snapshot could not be read safely: ${error.message}`);
|
|
1090
1549
|
throw new WorkflowError("RESTORE", "workflow snapshot could not be read safely");
|
|
1091
1550
|
}
|
|
1092
|
-
if (!isOwnedWorkflowSnapshot(cloned)) throw new WorkflowError("RESTORE", "workflow snapshot is inconsistent",
|
|
1551
|
+
if (!isOwnedWorkflowSnapshot(cloned)) throw new WorkflowError("RESTORE", "workflow snapshot is inconsistent", scanSnapshotContext(cloned));
|
|
1093
1552
|
if (id !== void 0 && cloned.id !== id) throw new WorkflowError("RESTORE", `workflow snapshot '${cloned.id}' does not match storage key '${id}'`, {
|
|
1094
1553
|
requested: id,
|
|
1095
1554
|
payload: cloned.id
|
|
@@ -1097,7 +1556,49 @@ function cloneWorkflowSnapshot(input, id) {
|
|
|
1097
1556
|
return cloned;
|
|
1098
1557
|
}
|
|
1099
1558
|
/**
|
|
1100
|
-
*
|
|
1559
|
+
* Validates and owns one list of task activity claims.
|
|
1560
|
+
*
|
|
1561
|
+
* @remarks
|
|
1562
|
+
* The one cloner behind both claim lists of a task activity frame — its `operations` and its
|
|
1563
|
+
* `constraints` — because {@link import('./types.js').TaskOperation} and
|
|
1564
|
+
* {@link import('./types.js').TaskConstraint} are the same {@link import('./types.js').TaskClaim}
|
|
1565
|
+
* shape. An omitted
|
|
1566
|
+
* list is an empty one. Each member is read exactly once inside the caller's protected boundary
|
|
1567
|
+
* and returned frozen; the semantic pass over the copied values is
|
|
1568
|
+
* {@link import('./validators.js').isTaskClaimList}, so this cloner refuses only what it cannot
|
|
1569
|
+
* read: a non-array list, a non-record member, a hostile prototype, or an unexpected key.
|
|
1570
|
+
*
|
|
1571
|
+
* @param input - The untrusted claim list
|
|
1572
|
+
* @param noun - The singular claim noun the refusal message names, pluralized by adding `s`
|
|
1573
|
+
* @returns The owned frozen claims, in input order
|
|
1574
|
+
* @throws {WorkflowError} With `MUTATION` when the list or one of its members cannot be read
|
|
1575
|
+
*
|
|
1576
|
+
* @example
|
|
1577
|
+
* ```ts
|
|
1578
|
+
* cloneTaskClaims([{ id: 'fetch', name: 'Fetch', started: 1 }], 'operation')
|
|
1579
|
+
* ```
|
|
1580
|
+
*/
|
|
1581
|
+
function cloneTaskClaims(input, noun) {
|
|
1582
|
+
const inputs = input === void 0 ? [] : isArray(input) ? [...input] : void 0;
|
|
1583
|
+
if (inputs === void 0) throw new WorkflowError("MUTATION", `task activity ${noun}s must be an array`);
|
|
1584
|
+
const claims = [];
|
|
1585
|
+
for (const claim of inputs) {
|
|
1586
|
+
if (!isRecord(claim)) throw new WorkflowError("MUTATION", `task activity contains an invalid ${noun}`);
|
|
1587
|
+
const prototype = Object.getPrototypeOf(claim);
|
|
1588
|
+
if (prototype !== Object.prototype && prototype !== null || !Object.keys(claim).every((key) => key === "id" || key === "name" || key === "started")) throw new WorkflowError("MUTATION", `task activity contains an invalid ${noun}`);
|
|
1589
|
+
const id = claim.id;
|
|
1590
|
+
const name = claim.name;
|
|
1591
|
+
const started = claim.started;
|
|
1592
|
+
claims.push(Object.freeze({
|
|
1593
|
+
id,
|
|
1594
|
+
name,
|
|
1595
|
+
started
|
|
1596
|
+
}));
|
|
1597
|
+
}
|
|
1598
|
+
return claims;
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Validates and clones one complete task activity frame.
|
|
1101
1602
|
*
|
|
1102
1603
|
* @remarks
|
|
1103
1604
|
* This is the hostile boundary behind task reports and snapshot hydration. Supplying
|
|
@@ -1121,22 +1622,7 @@ function cloneTaskActivity(input, updated) {
|
|
|
1121
1622
|
const operationsInput = input.operations;
|
|
1122
1623
|
const constraintsInput = input.constraints;
|
|
1123
1624
|
const accepted = updated === void 0 ? input.updated : updated;
|
|
1124
|
-
const
|
|
1125
|
-
if (operationInputs === void 0) throw new WorkflowError("MUTATION", "task activity operations must be an array");
|
|
1126
|
-
const operations = [];
|
|
1127
|
-
for (const operation of operationInputs) {
|
|
1128
|
-
if (!isRecord(operation)) throw new WorkflowError("MUTATION", "task activity contains an invalid operation");
|
|
1129
|
-
const operationPrototype = Object.getPrototypeOf(operation);
|
|
1130
|
-
if (operationPrototype !== Object.prototype && operationPrototype !== null || !Object.keys(operation).every((key) => key === "id" || key === "name" || key === "started")) throw new WorkflowError("MUTATION", "task activity contains an invalid operation");
|
|
1131
|
-
const id = operation.id;
|
|
1132
|
-
const name = operation.name;
|
|
1133
|
-
const started = operation.started;
|
|
1134
|
-
operations.push(Object.freeze({
|
|
1135
|
-
id,
|
|
1136
|
-
name,
|
|
1137
|
-
started
|
|
1138
|
-
}));
|
|
1139
|
-
}
|
|
1625
|
+
const operations = cloneTaskClaims(operationsInput, "operation");
|
|
1140
1626
|
let progress;
|
|
1141
1627
|
if (progressInput !== void 0) {
|
|
1142
1628
|
if (!isRecord(progressInput)) throw new WorkflowError("MUTATION", "task activity contains invalid progress");
|
|
@@ -1151,22 +1637,7 @@ function cloneTaskActivity(input, updated) {
|
|
|
1151
1637
|
...message === void 0 ? {} : { message }
|
|
1152
1638
|
});
|
|
1153
1639
|
}
|
|
1154
|
-
const
|
|
1155
|
-
if (constraintInputs === void 0) throw new WorkflowError("MUTATION", "task activity constraints must be an array");
|
|
1156
|
-
const constraints = [];
|
|
1157
|
-
for (const constraint of constraintInputs) {
|
|
1158
|
-
if (!isRecord(constraint)) throw new WorkflowError("MUTATION", "task activity contains an invalid constraint");
|
|
1159
|
-
const constraintPrototype = Object.getPrototypeOf(constraint);
|
|
1160
|
-
if (constraintPrototype !== Object.prototype && constraintPrototype !== null || !Object.keys(constraint).every((key) => key === "id" || key === "name" || key === "started")) throw new WorkflowError("MUTATION", "task activity contains an invalid constraint");
|
|
1161
|
-
const id = constraint.id;
|
|
1162
|
-
const name = constraint.name;
|
|
1163
|
-
const started = constraint.started;
|
|
1164
|
-
constraints.push(Object.freeze({
|
|
1165
|
-
id,
|
|
1166
|
-
name,
|
|
1167
|
-
started
|
|
1168
|
-
}));
|
|
1169
|
-
}
|
|
1640
|
+
const constraints = cloneTaskClaims(constraintsInput, "constraint");
|
|
1170
1641
|
const activity = Object.freeze({
|
|
1171
1642
|
...note === void 0 ? {} : { note },
|
|
1172
1643
|
...progress === void 0 ? {} : { progress },
|
|
@@ -1184,8 +1655,8 @@ function cloneTaskActivity(input, updated) {
|
|
|
1184
1655
|
//#endregion
|
|
1185
1656
|
//#region src/core/shapers.ts
|
|
1186
1657
|
/**
|
|
1187
|
-
*
|
|
1188
|
-
* `
|
|
1658
|
+
* Describes the shape of a {@link import('./types.js').TaskDefinition} — identity plus an optional
|
|
1659
|
+
* `behavior` behavior reference (a plain registry-key string, min length 1). `description` is
|
|
1189
1660
|
* optional prose.
|
|
1190
1661
|
*/
|
|
1191
1662
|
var taskShape = objectShape({
|
|
@@ -1198,7 +1669,7 @@ var taskShape = objectShape({
|
|
|
1198
1669
|
description: "Human-readable task name."
|
|
1199
1670
|
}),
|
|
1200
1671
|
description: optionalShape(stringShape({ description: "Optional task description." })),
|
|
1201
|
-
|
|
1672
|
+
behavior: optionalShape(stringShape({
|
|
1202
1673
|
min: 1,
|
|
1203
1674
|
description: "The registered behavior name to invoke (a registry key, not a label); omitted has no handler."
|
|
1204
1675
|
})),
|
|
@@ -1213,7 +1684,7 @@ var taskShape = objectShape({
|
|
|
1213
1684
|
}))
|
|
1214
1685
|
});
|
|
1215
1686
|
/**
|
|
1216
|
-
*
|
|
1687
|
+
* Describes the shape of a {@link import('./types.js').PhaseDefinition} — identity, its ordered
|
|
1217
1688
|
* {@link taskShape} tasks, and an optional positive-integer `concurrency` throttle
|
|
1218
1689
|
* (max tasks in flight; omitted ⇒ unbounded).
|
|
1219
1690
|
*/
|
|
@@ -1235,7 +1706,7 @@ var phaseShape = objectShape({
|
|
|
1235
1706
|
bail: optionalShape(literalShape([true, false], { description: "Per-phase failure-policy override; omitted inherits the workflow bail." }))
|
|
1236
1707
|
});
|
|
1237
1708
|
/**
|
|
1238
|
-
*
|
|
1709
|
+
* Describes the shape of a {@link import('./types.js').WorkflowDefinition} — the contract root:
|
|
1239
1710
|
* identity, its ordered {@link phaseShape} phases, and the optional `bail` boolean
|
|
1240
1711
|
* failure policy (the literal pair `true`/`false`, the runtime mirror of the boolean
|
|
1241
1712
|
* toggle; omitted ⇒ the graceful default).
|
|
@@ -1254,13 +1725,13 @@ var workflowShape = objectShape({
|
|
|
1254
1725
|
bail: optionalShape(literalShape([true, false], { description: "Failure policy: false (default) continues gracefully, true halts on the first failure." }))
|
|
1255
1726
|
});
|
|
1256
1727
|
/**
|
|
1257
|
-
*
|
|
1728
|
+
* Describes the shape of a {@link import('./types.js').TaskUpdate} — a partial edit to a
|
|
1258
1729
|
* `pending` task's `name` / `description`, both optional.
|
|
1259
1730
|
*
|
|
1260
1731
|
* @remarks
|
|
1261
1732
|
* Mirrors {@link taskShape}'s `name` / `description` constraints exactly (a provided
|
|
1262
|
-
* `name` still has `minLength: 1`); never `id` / `
|
|
1263
|
-
* are not patchable fields
|
|
1733
|
+
* `name` still has `minLength: 1`); never `id` / `behavior` / `retries` / `timeout` (those
|
|
1734
|
+
* are not patchable fields).
|
|
1264
1735
|
*/
|
|
1265
1736
|
var taskUpdateShape = objectShape({
|
|
1266
1737
|
name: optionalShape(stringShape({
|
|
@@ -1270,13 +1741,13 @@ var taskUpdateShape = objectShape({
|
|
|
1270
1741
|
description: optionalShape(stringShape({ description: "New task description." }))
|
|
1271
1742
|
});
|
|
1272
1743
|
/**
|
|
1273
|
-
*
|
|
1744
|
+
* Describes the shape of a {@link import('./types.js').PhaseUpdate} — a partial edit to a
|
|
1274
1745
|
* `pending` phase's `name` / `description` / `concurrency` / `bail`, all optional.
|
|
1275
1746
|
*
|
|
1276
1747
|
* @remarks
|
|
1277
1748
|
* Mirrors {@link phaseShape}'s corresponding field constraints exactly; never `id` /
|
|
1278
1749
|
* `tasks` (structural children change through the phase's own `add` / `remove` /
|
|
1279
|
-
* `move`, not a patch
|
|
1750
|
+
* `move`, not a patch).
|
|
1280
1751
|
*/
|
|
1281
1752
|
var phaseUpdateShape = objectShape({
|
|
1282
1753
|
name: optionalShape(stringShape({
|
|
@@ -1293,40 +1764,42 @@ var phaseUpdateShape = objectShape({
|
|
|
1293
1764
|
//#endregion
|
|
1294
1765
|
//#region src/core/stores/DatabaseWorkflowStore.ts
|
|
1295
1766
|
/**
|
|
1296
|
-
*
|
|
1297
|
-
* workflow's durable run
|
|
1767
|
+
* Implements a {@link WorkflowStoreInterface} backed by one table of the `databases` layer — a
|
|
1768
|
+
* workflow's durable run state is a row, so persistence reduces to keyed point-access
|
|
1298
1769
|
* (`get` / `set` / `delete`) over a `TableInterface`, the driver-pluggable twin of the
|
|
1299
1770
|
* plain-`Map` {@link import('./MemoryWorkflowStore.js').MemoryWorkflowStore}.
|
|
1300
1771
|
*
|
|
1301
1772
|
* @remarks
|
|
1302
1773
|
* The store is driver-agnostic: it holds a single {@link TableInterface} whose backend
|
|
1303
1774
|
* (memory, JSON, SQLite, IndexedDB) is chosen by whoever builds it (the factories), so a
|
|
1304
|
-
* JSON / SQLite / IndexedDB backend swaps in
|
|
1775
|
+
* JSON / SQLite / IndexedDB backend swaps in without touching the runner or the entity tree
|
|
1305
1776
|
* — the same seam as `@orkestrel/queue`'s `DatabaseQueueStore`.
|
|
1306
1777
|
* The driver defaults to memory ({@link import('../factories.js').createDatabaseWorkflowStore}
|
|
1307
|
-
* passes `createMemoryDriver()`), so it
|
|
1778
|
+
* passes `createMemoryDriver()`), so it also works in memory out of the box; you opt into the
|
|
1308
1779
|
* durable plumbing by passing a JSON / SQLite / IndexedDB driver.
|
|
1309
1780
|
*
|
|
1310
|
-
* The {@link WorkflowSnapshot} is stored as
|
|
1781
|
+
* The {@link WorkflowSnapshot} is stored as one opaque JSON column — the table is a row of
|
|
1311
1782
|
* `{ id; snapshot }` ({@link WorkflowSnapshotRow}), the snapshot the whole JSON blob (a `rawShape`
|
|
1312
1783
|
* column the factory builds) — exactly as `DatabaseQueueStore` stores its `input`. The snapshot is
|
|
1313
|
-
* already a
|
|
1784
|
+
* already a complete, self-contained, pure-JSON payload, so storing it whole is lossless and
|
|
1314
1785
|
* sidesteps a TS2589 instantiation-depth blow-up: a structured multi-column table would force the
|
|
1315
1786
|
* contract to `Infer` the deeply-nested snapshot shape (workflow → phases → tasks → results),
|
|
1316
1787
|
* tripping the compiler — one JSON column keeps the row type flat (`snapshot` reads back as `unknown`).
|
|
1317
1788
|
*
|
|
1318
|
-
* - **`set(snapshot)` upserts under the snapshot's
|
|
1789
|
+
* - **`set(snapshot)` upserts under the snapshot's own `id`** (no separate id param) — it writes
|
|
1319
1790
|
* the row `{ id: snapshot.id, snapshot }`.
|
|
1320
|
-
* - **`get(id)` resolves the stored snapshot for an id**, narrowing the opaque JSON
|
|
1321
|
-
* a {@link WorkflowSnapshot}
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1791
|
+
* - **`get(id)` resolves the stored snapshot for an id**, owning and narrowing the opaque JSON
|
|
1792
|
+
* column back to a {@link WorkflowSnapshot} through
|
|
1793
|
+
* {@link import('../cloners.js').cloneWorkflowSnapshot}, whose semantic pass is
|
|
1794
|
+
* {@link import('../validators.js').isOwnedWorkflowSnapshot} — the boundary narrow for
|
|
1795
|
+
* an untrusted storage read — or `undefined` if none is stored. A present snapshot whose own id
|
|
1796
|
+
* differs from the requested key rejects with normalized `RESTORE` evidence.
|
|
1324
1797
|
* - **`delete(id)` drops a snapshot by id**; an absent id is a no-op (no throw).
|
|
1325
1798
|
*
|
|
1326
|
-
*
|
|
1799
|
+
* unlike the server package's `SessionStoreInterface` there is no
|
|
1327
1800
|
* idle-TTL / eviction — a persisted run-state is durable orchestration state that lives until an
|
|
1328
|
-
* explicit `delete`. The public surface is
|
|
1329
|
-
*
|
|
1801
|
+
* explicit `delete`. The public surface is exactly `get` / `set` / `delete` — no extra members (the
|
|
1802
|
+
* guide's method bijection with {@link WorkflowStoreInterface}). Restore stays a caller concern: read a
|
|
1330
1803
|
* snapshot back and rebuild the live tree with {@link import('../factories.js').createRestoredWorkflow}.
|
|
1331
1804
|
*
|
|
1332
1805
|
* @example
|
|
@@ -1345,7 +1818,7 @@ var phaseUpdateShape = objectShape({
|
|
|
1345
1818
|
var DatabaseWorkflowStore = class {
|
|
1346
1819
|
#table;
|
|
1347
1820
|
/**
|
|
1348
|
-
*
|
|
1821
|
+
* Wraps a table as a workflow store.
|
|
1349
1822
|
*
|
|
1350
1823
|
* @param table - The {@link TableInterface} holding the snapshots — its row is the
|
|
1351
1824
|
* {@link WorkflowSnapshotRow} `{ id; snapshot }` shape (the snapshot one opaque JSON column)
|
|
@@ -1353,13 +1826,13 @@ var DatabaseWorkflowStore = class {
|
|
|
1353
1826
|
constructor(table) {
|
|
1354
1827
|
this.#table = table;
|
|
1355
1828
|
}
|
|
1356
|
-
/**
|
|
1829
|
+
/** Resolves and key-checks the snapshot for `id`, narrowing the opaque column to `WorkflowSnapshot`. */
|
|
1357
1830
|
async get(id) {
|
|
1358
1831
|
const row = await this.#table.get(id);
|
|
1359
1832
|
if (row === void 0) return void 0;
|
|
1360
1833
|
return cloneWorkflowSnapshot(row.snapshot, id);
|
|
1361
1834
|
}
|
|
1362
|
-
/**
|
|
1835
|
+
/** Inserts or replaces under the snapshot's own `id` (no separate id param) — the row is `{ id, snapshot }`. */
|
|
1363
1836
|
async set(snapshot) {
|
|
1364
1837
|
const owned = cloneWorkflowSnapshot(snapshot);
|
|
1365
1838
|
await this.#table.set({
|
|
@@ -1367,7 +1840,7 @@ var DatabaseWorkflowStore = class {
|
|
|
1367
1840
|
snapshot: owned
|
|
1368
1841
|
});
|
|
1369
1842
|
}
|
|
1370
|
-
/**
|
|
1843
|
+
/** Drops a snapshot by id; an absent id is a no-op (no throw). */
|
|
1371
1844
|
async delete(id) {
|
|
1372
1845
|
await this.#table.remove(id);
|
|
1373
1846
|
}
|
|
@@ -1375,28 +1848,25 @@ var DatabaseWorkflowStore = class {
|
|
|
1375
1848
|
//#endregion
|
|
1376
1849
|
//#region src/core/stores/MemoryWorkflowStore.ts
|
|
1377
1850
|
/**
|
|
1378
|
-
*
|
|
1379
|
-
* {@link WorkflowSnapshot}s keyed by workflow id, the
|
|
1380
|
-
* {@link import('../factories.js').createMemoryWorkflowStore} builds.
|
|
1851
|
+
* Implements the in-memory {@link WorkflowStoreInterface} — a process-lifetime `Map` of
|
|
1852
|
+
* {@link WorkflowSnapshot}s keyed by workflow id, the default store
|
|
1853
|
+
* {@link import('../factories.js').createMemoryWorkflowStore} builds. It expires nothing: a
|
|
1854
|
+
* persisted snapshot lives until an explicit `delete`.
|
|
1381
1855
|
*
|
|
1382
1856
|
* @remarks
|
|
1383
|
-
* A plain `Map<string, WorkflowSnapshot>` (
|
|
1384
|
-
* self-contained JSON, so no encoding is needed for the memory tier).
|
|
1385
|
-
*
|
|
1386
|
-
* NO idle-TTL and NO eviction: a persisted workflow run-state is durable orchestration state
|
|
1387
|
-
* that lives until an explicit `delete`, never silently aging out (a run that vanished
|
|
1388
|
-
* mid-flight would be a silent data loss, not a freed session). A durable backend (JSON /
|
|
1389
|
-
* SQLite / IndexedDB) swaps in through the SAME interface without touching the runner or the
|
|
1857
|
+
* A plain `Map<string, WorkflowSnapshot>` (the snapshot is already pure,
|
|
1858
|
+
* self-contained JSON, so no encoding is needed for the memory tier). A durable backend (JSON /
|
|
1859
|
+
* SQLite / IndexedDB) swaps in through the same interface without touching the runner or the
|
|
1390
1860
|
* entity tree — its driver-pluggable twin is
|
|
1391
1861
|
* {@link import('./DatabaseWorkflowStore.js').DatabaseWorkflowStore} (the snapshot as one opaque
|
|
1392
1862
|
* JSON column), exactly as `@orkestrel/queue`'s `MemoryQueueStore`
|
|
1393
1863
|
* twins `DatabaseQueueStore`.
|
|
1394
1864
|
*
|
|
1395
1865
|
* - **`get` resolves the persisted snapshot for an id**, or `undefined` if none is stored.
|
|
1396
|
-
* - **`set` inserts / replaces under the snapshot's
|
|
1866
|
+
* - **`set` inserts / replaces under the snapshot's own `id`** (no separate id param).
|
|
1397
1867
|
* - **`delete` drops a snapshot by id**; an absent id is a no-op (no throw).
|
|
1398
1868
|
*
|
|
1399
|
-
* The public surface is
|
|
1869
|
+
* The public surface is exactly `get` / `set` / `delete` — no extra members (the guide's method
|
|
1400
1870
|
* bijection with {@link WorkflowStoreInterface}). Restore is a caller concern: read a snapshot
|
|
1401
1871
|
* back and rebuild the live tree with {@link import('../factories.js').createRestoredWorkflow}.
|
|
1402
1872
|
*
|
|
@@ -1431,35 +1901,35 @@ var MemoryWorkflowStore = class {
|
|
|
1431
1901
|
//#endregion
|
|
1432
1902
|
//#region src/core/tasks/Task.ts
|
|
1433
1903
|
/**
|
|
1434
|
-
*
|
|
1435
|
-
* synchronous task whose explicit {@link
|
|
1904
|
+
* Implements the live leaf state machine (W-b) for one task — an observable, guarded
|
|
1905
|
+
* synchronous task whose explicit {@link LifecycleStatus} advances through the declared
|
|
1436
1906
|
* transitions, recording a {@link TaskResult} on a terminal outcome.
|
|
1437
1907
|
*
|
|
1438
1908
|
* @remarks
|
|
1439
|
-
* - **Guarded transitions
|
|
1909
|
+
* - **Guarded transitions.** `start` (→ `running`), then `complete(value)`
|
|
1440
1910
|
* (→ `completed`, records a {@link import('@orkestrel/contract').Success}), `fail(error)`
|
|
1441
1911
|
* (→ `failed`, records a {@link import('@orkestrel/contract').Failure}), `skip` (→ `skipped`),
|
|
1442
|
-
* `stop` (→ `stopped`). Each consults {@link canTransitionTask}
|
|
1443
|
-
* `TRANSITION` {@link WorkflowError} on an illegal move (
|
|
1444
|
-
* task) — the legal graph is the single source of truth, so the leaf can never
|
|
1445
|
-
* impossible state.
|
|
1912
|
+
* `stop` (→ `stopped`). Each consults {@link canTransitionTask} first and throws a
|
|
1913
|
+
* `TRANSITION` {@link WorkflowError} on an illegal move (for example, completing a
|
|
1914
|
+
* non-`running` task) — the legal graph is the single source of truth, so the leaf can never
|
|
1915
|
+
* reach an impossible state.
|
|
1446
1916
|
* - **Snapshot fidelity.** A leaf needs no override: `skipped` / `stopped` are explicit terminal
|
|
1447
1917
|
* statuses, and restore reinstates the leaf directly from {@link TaskSnapshot.status}.
|
|
1448
1918
|
* - **The cascade.** Every status change records its boxed result (when any), fires the leaf's
|
|
1449
|
-
*
|
|
1450
|
-
* transition propagates
|
|
1451
|
-
* order means an observer sees the
|
|
1919
|
+
* own event, then calls the parent phase's `#recompute` (injected at construction) so the
|
|
1920
|
+
* transition propagates up (Task → Phase → Workflow re-derive). The own-event-before-cascade
|
|
1921
|
+
* order means an observer sees the cause (this leaf changed) before the effect (the parents
|
|
1452
1922
|
* re-derive) — the project precedent (`Runner.#settle` emits its own `fail` before propagating).
|
|
1453
|
-
* - **Observable
|
|
1454
|
-
* matching event strictly
|
|
1923
|
+
* - **Observable.** The owned {@link emitter} ({@link TaskEventMap}) fires the
|
|
1924
|
+
* matching event strictly after the state change, before the cascade; the emitter isolates
|
|
1455
1925
|
* a listener throw and routes it to its `error` handler (the `error` option), so a buggy
|
|
1456
1926
|
* observer can never corrupt a transition.
|
|
1457
|
-
* - **Declarative config
|
|
1927
|
+
* - **Declarative config.** `behavior` / `retries` / `timeout` persist in a
|
|
1458
1928
|
* {@link TaskSnapshot} (like a phase's `bail` / `concurrency`), carried verbatim from the
|
|
1459
1929
|
* matching {@link import('../types.js').TaskDefinition} / {@link TaskSnapshot} field. `handler`
|
|
1460
|
-
* is the
|
|
1930
|
+
* is the runtime-only counterpart — `behavior` resolved once at construction against the
|
|
1461
1931
|
* workflow-level {@link import('../types.js').WorkflowOptions.functions} registry — and is
|
|
1462
|
-
*
|
|
1932
|
+
* never persisted; `undefined` when `behavior` is omitted or unregistered. Only omission is a
|
|
1463
1933
|
* deliberate no-op; unresolved named work is rejected before dispatch.
|
|
1464
1934
|
*/
|
|
1465
1935
|
var Task = class {
|
|
@@ -1472,7 +1942,8 @@ var Task = class {
|
|
|
1472
1942
|
#status;
|
|
1473
1943
|
#result;
|
|
1474
1944
|
#name;
|
|
1475
|
-
#
|
|
1945
|
+
#description;
|
|
1946
|
+
#behavior;
|
|
1476
1947
|
#retries;
|
|
1477
1948
|
#timeout;
|
|
1478
1949
|
#attempts;
|
|
@@ -1485,7 +1956,7 @@ var Task = class {
|
|
|
1485
1956
|
#paused;
|
|
1486
1957
|
#gate;
|
|
1487
1958
|
#timerSignal;
|
|
1488
|
-
constructor(context, phase, workflow, recompute, options, status = "pending", result,
|
|
1959
|
+
constructor(context, phase, workflow, recompute, options, status = "pending", result, behavior, retries, timeout, metadata = {}, attempts = 0, activity, handler, silence) {
|
|
1489
1960
|
this.#context = buildTaskContext(context.phase, context);
|
|
1490
1961
|
this.#phase = phase;
|
|
1491
1962
|
this.#workflow = workflow;
|
|
@@ -1507,11 +1978,8 @@ var Task = class {
|
|
|
1507
1978
|
this.#status = status;
|
|
1508
1979
|
this.#result = result;
|
|
1509
1980
|
this.#name = context.name;
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
value: context.description
|
|
1513
|
-
});
|
|
1514
|
-
this.#run = run;
|
|
1981
|
+
this.#description = context.description;
|
|
1982
|
+
this.#behavior = behavior;
|
|
1515
1983
|
this.#retries = retries;
|
|
1516
1984
|
this.#timeout = timeout;
|
|
1517
1985
|
this.#attempts = attempts;
|
|
@@ -1537,6 +2005,9 @@ var Task = class {
|
|
|
1537
2005
|
get name() {
|
|
1538
2006
|
return this.#name;
|
|
1539
2007
|
}
|
|
2008
|
+
get description() {
|
|
2009
|
+
return this.#description;
|
|
2010
|
+
}
|
|
1540
2011
|
get context() {
|
|
1541
2012
|
return this.#context;
|
|
1542
2013
|
}
|
|
@@ -1555,8 +2026,8 @@ var Task = class {
|
|
|
1555
2026
|
get attempts() {
|
|
1556
2027
|
return this.#attempts;
|
|
1557
2028
|
}
|
|
1558
|
-
get
|
|
1559
|
-
return this.#
|
|
2029
|
+
get behavior() {
|
|
2030
|
+
return this.#behavior;
|
|
1560
2031
|
}
|
|
1561
2032
|
get handler() {
|
|
1562
2033
|
return this.#handler;
|
|
@@ -1595,7 +2066,7 @@ var Task = class {
|
|
|
1595
2066
|
this.#activity = cloneTaskActivity({}, this.#stamp());
|
|
1596
2067
|
this.#arm();
|
|
1597
2068
|
this.#emitter.emit("start", this.id);
|
|
1598
|
-
this.#
|
|
2069
|
+
this.#recompute();
|
|
1599
2070
|
}
|
|
1600
2071
|
complete(value) {
|
|
1601
2072
|
let owned;
|
|
@@ -1612,7 +2083,7 @@ var Task = class {
|
|
|
1612
2083
|
value: owned
|
|
1613
2084
|
}));
|
|
1614
2085
|
this.#emitter.emit("complete", result);
|
|
1615
|
-
this.#
|
|
2086
|
+
this.#recompute();
|
|
1616
2087
|
}
|
|
1617
2088
|
fail(error) {
|
|
1618
2089
|
const origin = error.origin === "handler" || error.origin === "timeout" || error.origin === "recovery" ? error.origin : "handler";
|
|
@@ -1627,21 +2098,21 @@ var Task = class {
|
|
|
1627
2098
|
})
|
|
1628
2099
|
}));
|
|
1629
2100
|
this.#emitter.emit("fail", result);
|
|
1630
|
-
this.#
|
|
2101
|
+
this.#recompute();
|
|
1631
2102
|
}
|
|
1632
2103
|
skip() {
|
|
1633
2104
|
this.#transition("skipped");
|
|
1634
2105
|
this.#finish();
|
|
1635
2106
|
this.#abort.abort();
|
|
1636
2107
|
this.#emitter.emit("skip");
|
|
1637
|
-
this.#
|
|
2108
|
+
this.#recompute();
|
|
1638
2109
|
}
|
|
1639
2110
|
stop() {
|
|
1640
2111
|
this.#transition("stopped");
|
|
1641
2112
|
this.#finish();
|
|
1642
2113
|
this.#abort.abort();
|
|
1643
2114
|
this.#emitter.emit("stop");
|
|
1644
|
-
this.#
|
|
2115
|
+
this.#recompute();
|
|
1645
2116
|
}
|
|
1646
2117
|
report(input) {
|
|
1647
2118
|
if (this.#status !== "running") return failure(new WorkflowError("TRANSITION", `task '${this.id}' cannot report while '${this.#status}'`, {
|
|
@@ -1669,7 +2140,7 @@ var Task = class {
|
|
|
1669
2140
|
pause() {
|
|
1670
2141
|
if (this.#paused || this.#status !== "pending" && this.#status !== "running") return;
|
|
1671
2142
|
this.#paused = true;
|
|
1672
|
-
this.#gate =
|
|
2143
|
+
this.#gate = Promise.withResolvers();
|
|
1673
2144
|
this.#emitter.emit("pause");
|
|
1674
2145
|
}
|
|
1675
2146
|
resume() {
|
|
@@ -1682,12 +2153,12 @@ var Task = class {
|
|
|
1682
2153
|
return this.#paused && this.#gate !== void 0 ? this.#gate.promise : Promise.resolve();
|
|
1683
2154
|
}
|
|
1684
2155
|
/**
|
|
1685
|
-
*
|
|
2156
|
+
* Applies a validated declarative patch to self (`name` / `description`).
|
|
1686
2157
|
*
|
|
1687
2158
|
* @remarks
|
|
1688
|
-
* Defense-in-depth
|
|
1689
|
-
* {@link import('../types.js').TaskManagerInterface.update} gates
|
|
1690
|
-
* exists + `pending`), so this is the second, redundant check — it
|
|
2159
|
+
* Defense-in-depth: the owning
|
|
2160
|
+
* {@link import('../types.js').TaskManagerInterface.update} gates first (target
|
|
2161
|
+
* exists + `pending`), so this is the second, redundant check — it throws a
|
|
1691
2162
|
* `MUTATION` {@link WorkflowError} unless this task's own `status` is `pending`.
|
|
1692
2163
|
*
|
|
1693
2164
|
* @param value - The {@link TaskUpdate} fields to apply
|
|
@@ -1702,21 +2173,18 @@ var Task = class {
|
|
|
1702
2173
|
status: this.#status
|
|
1703
2174
|
});
|
|
1704
2175
|
if (value.name !== void 0) this.#name = value.name;
|
|
1705
|
-
if (value.description !== void 0)
|
|
1706
|
-
configurable: true,
|
|
1707
|
-
value: value.description
|
|
1708
|
-
});
|
|
2176
|
+
if (value.description !== void 0) this.#description = value.description;
|
|
1709
2177
|
}
|
|
1710
2178
|
snapshot() {
|
|
1711
2179
|
return {
|
|
1712
2180
|
id: this.id,
|
|
1713
2181
|
name: this.name,
|
|
1714
|
-
...this
|
|
2182
|
+
...this.#description === void 0 ? {} : { description: this.#description },
|
|
1715
2183
|
status: this.#status,
|
|
1716
2184
|
...this.#result === void 0 ? {} : { result: this.#result },
|
|
1717
2185
|
metadata: this.#metadata,
|
|
1718
2186
|
attempts: this.#attempts,
|
|
1719
|
-
...this.#
|
|
2187
|
+
...this.#behavior === void 0 ? {} : { behavior: this.#behavior },
|
|
1720
2188
|
...this.#retries === void 0 ? {} : { retries: this.#retries },
|
|
1721
2189
|
...this.#timeout === void 0 ? {} : { timeout: this.#timeout },
|
|
1722
2190
|
...this.#activity === void 0 ? {} : { activity: this.#activity }
|
|
@@ -1743,9 +2211,6 @@ var Task = class {
|
|
|
1743
2211
|
this.#result = frozen;
|
|
1744
2212
|
return frozen;
|
|
1745
2213
|
}
|
|
1746
|
-
#escalate() {
|
|
1747
|
-
this.#recompute();
|
|
1748
|
-
}
|
|
1749
2214
|
#touch() {
|
|
1750
2215
|
if (this.#activity === void 0) return;
|
|
1751
2216
|
this.#activity = Object.freeze({
|
|
@@ -1790,25 +2255,30 @@ var Task = class {
|
|
|
1790
2255
|
//#endregion
|
|
1791
2256
|
//#region src/core/tasks/TaskManager.ts
|
|
1792
2257
|
/**
|
|
1793
|
-
*
|
|
1794
|
-
* tasks —
|
|
1795
|
-
* preserved across an interior `skip` / `remove`.
|
|
2258
|
+
* Implements the lean child manager of a {@link import('../phases/Phase.js').Phase}'s live
|
|
2259
|
+
* tasks — the task vocabulary over one insertion-ordered {@link Collection}, so positional order
|
|
2260
|
+
* is preserved across an interior `skip` / `remove`.
|
|
1796
2261
|
*
|
|
1797
2262
|
* @remarks
|
|
1798
|
-
* - **
|
|
1799
|
-
* `
|
|
1800
|
-
* `
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
1803
|
-
* - **
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
1807
|
-
*
|
|
1808
|
-
*
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
2263
|
+
* - **One shared store.** The insertion-ordered `Map`, the reorder step, the bounds checks, and
|
|
2264
|
+
* the gated `add` / `remove` / `move` / `update` all live in {@link Collection}, built with the
|
|
2265
|
+
* `task` noun its refusals name and the compiled {@link taskUpdateShape} guard. This class adds
|
|
2266
|
+
* the domain accessors `task` / `tasks` and nothing else, so the task and phase managers cannot
|
|
2267
|
+
* drift apart.
|
|
2268
|
+
* - **Positional store.** `append` adds one live {@link TaskInterface} at the end (the build-time
|
|
2269
|
+
* wiring path), `task(id)` looks one up, `tasks()` lists them in positional order, `count` is
|
|
2270
|
+
* the tally. A `skip` is a status change on a stored task (never a removal), so order survives
|
|
2271
|
+
* it; a snapshot RESTORE re-`append`s in the snapshot's order, reproducing it exactly.
|
|
2272
|
+
* - **Gated mutation API.** `add` / `remove` / `move` / `update` are the graceful
|
|
2273
|
+
* `Result` counterparts to `append`, gating only on the target's own existence/status/id/bounds
|
|
2274
|
+
* — a duplicate id, an absent/non-`pending` target, an out-of-bounds `index`, or a patch that
|
|
2275
|
+
* fails {@link taskUpdateShape} validation all fail gracefully with a `MUTATION`
|
|
2276
|
+
* {@link WorkflowError} instead of throwing.
|
|
2277
|
+
* - **No batch matrix.** A phase's tasks are a fixed positional set, so
|
|
2278
|
+
* `.claude/rules/patterns.md` § Batch operations (the bulk verb
|
|
2279
|
+
* overloads) is deliberately omitted — no `remove` family lives here.
|
|
2280
|
+
* - **Event-free.** A purely structural container — the live {@link TaskInterface}s own their own
|
|
2281
|
+
* emitters; the manager observes nothing.
|
|
1812
2282
|
*
|
|
1813
2283
|
* @example
|
|
1814
2284
|
* ```ts
|
|
@@ -1819,105 +2289,84 @@ var Task = class {
|
|
|
1819
2289
|
* ```
|
|
1820
2290
|
*/
|
|
1821
2291
|
var TaskManager = class {
|
|
1822
|
-
#tasks =
|
|
1823
|
-
#isUpdate = compileGuard(taskUpdateShape);
|
|
2292
|
+
#tasks = new Collection("task", compileGuard(taskUpdateShape));
|
|
1824
2293
|
get count() {
|
|
1825
|
-
return this.#tasks.
|
|
2294
|
+
return this.#tasks.count;
|
|
1826
2295
|
}
|
|
1827
2296
|
append(task) {
|
|
1828
|
-
|
|
1829
|
-
this.#tasks.set(task.id, task);
|
|
2297
|
+
this.#tasks.append(task);
|
|
1830
2298
|
}
|
|
1831
2299
|
add(task, index) {
|
|
1832
|
-
|
|
1833
|
-
const at = index ?? this.#tasks.size;
|
|
1834
|
-
if (at < 0 || at > this.#tasks.size) return failure(new WorkflowError("MUTATION", `index '${at}' out of bounds`, { index: at }));
|
|
1835
|
-
this.#reorder(insertEntry([...this.#tasks.entries()], at, task.id, task));
|
|
1836
|
-
return success(task);
|
|
2300
|
+
return this.#tasks.add(task, index);
|
|
1837
2301
|
}
|
|
1838
2302
|
remove(id) {
|
|
1839
|
-
|
|
1840
|
-
if (target === void 0 || target.status !== "pending") return failure(new WorkflowError("MUTATION", `task '${id}' is not a pending task`, { id }));
|
|
1841
|
-
this.#tasks.delete(id);
|
|
1842
|
-
return success(target);
|
|
2303
|
+
return this.#tasks.remove(id);
|
|
1843
2304
|
}
|
|
1844
2305
|
move(id, index) {
|
|
1845
|
-
|
|
1846
|
-
if (target === void 0 || target.status !== "pending") return failure(new WorkflowError("MUTATION", `task '${id}' is not a pending task`, { id }));
|
|
1847
|
-
if (index < 0 || index >= this.#tasks.size) return failure(new WorkflowError("MUTATION", `index '${index}' out of bounds`, { index }));
|
|
1848
|
-
this.#reorder(moveEntry([...this.#tasks.entries()], id, index));
|
|
1849
|
-
return success(target);
|
|
2306
|
+
return this.#tasks.move(id, index);
|
|
1850
2307
|
}
|
|
1851
2308
|
update(id, patch) {
|
|
1852
|
-
|
|
1853
|
-
if (target === void 0 || target.status !== "pending") return failure(new WorkflowError("MUTATION", `task '${id}' is not a pending task`, { id }));
|
|
1854
|
-
if (!this.#isUpdate(patch)) return failure(new WorkflowError("MUTATION", `invalid patch for task '${id}'`, { id }));
|
|
1855
|
-
target.patch(patch);
|
|
1856
|
-
return success(target);
|
|
2309
|
+
return this.#tasks.update(id, patch);
|
|
1857
2310
|
}
|
|
1858
2311
|
task(id) {
|
|
1859
|
-
return this.#tasks.
|
|
2312
|
+
return this.#tasks.entry(id);
|
|
1860
2313
|
}
|
|
1861
2314
|
tasks() {
|
|
1862
|
-
return
|
|
1863
|
-
}
|
|
1864
|
-
#reorder(entries) {
|
|
1865
|
-
this.#tasks.clear();
|
|
1866
|
-
for (const [key, value] of entries) this.#tasks.set(key, value);
|
|
2315
|
+
return this.#tasks.entries();
|
|
1867
2316
|
}
|
|
1868
2317
|
};
|
|
1869
2318
|
//#endregion
|
|
1870
2319
|
//#region src/core/phases/Phase.ts
|
|
1871
2320
|
/**
|
|
1872
|
-
*
|
|
1873
|
-
* {@link
|
|
2321
|
+
* Implements the live derived state machine (W-b) for one phase — an observable whose
|
|
2322
|
+
* {@link LifecycleStatus} is computed from its tasks (never set directly) and recomputed
|
|
1874
2323
|
* reactively as a task transitions (the middle tier of the cascade).
|
|
1875
2324
|
*
|
|
1876
2325
|
* @remarks
|
|
1877
2326
|
* - **Derived status.** `status` is `#override` when one is in force, else
|
|
1878
2327
|
* {@link derivePhaseStatus} over the live tasks' statuses. `#recompute` (passed to
|
|
1879
|
-
* each child {@link Task}) re-derives on every child transition; a
|
|
1880
|
-
* event
|
|
1881
|
-
* - **Override
|
|
1882
|
-
* phase), overriding the derived value; the override is
|
|
1883
|
-
* `override` field and restored
|
|
1884
|
-
* - **Children
|
|
2328
|
+
* each child {@link Task}) re-derives on every child transition; a change emits the matching
|
|
2329
|
+
* event and escalates to the workflow (`#escalate`, the upward step of the cascade).
|
|
2330
|
+
* - **Override.** `skip` / `stop` force the phase's status (for example, skipping a whole
|
|
2331
|
+
* phase), overriding the derived value; the override is persisted in the snapshot's own
|
|
2332
|
+
* `override` field and restored directly (no divergence guess), so a forced phase round-trips.
|
|
2333
|
+
* - **Children.** `tasks` is the lean {@link TaskManager} (an accessor + `count`,
|
|
1885
2334
|
* no batch matrix); built positionally from the snapshot so order survives an interior `skip`.
|
|
1886
2335
|
* `results()` collects the settled tasks' {@link TaskResult}s (the phase tier of the result
|
|
1887
|
-
* tree); `workflow` navigates
|
|
1888
|
-
* - **Observable
|
|
2336
|
+
* tree); `workflow` navigates up to the live parent.
|
|
2337
|
+
* - **Observable.** The owned {@link emitter} ({@link PhaseEventMap}) fires
|
|
1889
2338
|
* `start` / `complete` / `fail` / `pause` / `resume` / `skip` / `stop` after the
|
|
1890
2339
|
* corresponding status or runtime-gate change. Status events fire after the phase recomputes
|
|
1891
2340
|
* and before it escalates to the workflow, preserving child/phase cause before parent effect.
|
|
1892
2341
|
* The emitter isolates a listener throw and routes it to its `error` handler (the `error`
|
|
1893
2342
|
* option); `fail` carries the failing task's {@link TaskResult}.
|
|
1894
|
-
* - **Structural API
|
|
2343
|
+
* - **Structural API.** `add` / `remove` / `move` / `update` gate before
|
|
1895
2344
|
* delegating to {@link tasks} (the manager gates the target's own existence/status/id/
|
|
1896
|
-
* bounds), then emit the matching {@link PhaseEventMap} event on success only.
|
|
2345
|
+
* bounds), then emit the matching {@link PhaseEventMap} event on success only. Native
|
|
1897
2346
|
* gating, purely from this phase's own derived `status` (no runner-installed hook): while
|
|
1898
|
-
* `pending`, any valid `index` is accepted; while `running`, `add` accepts
|
|
2347
|
+
* `pending`, any valid `index` is accepted; while `running`, `add` accepts only a pure
|
|
1899
2348
|
* append (a live runner subscribed to the `add` event picks it up), and `remove` / `move` /
|
|
1900
2349
|
* `update` always fail gracefully (the tasks are already handed to the execution
|
|
1901
2350
|
* substrate); while terminal, everything is refused.
|
|
1902
|
-
* - **Patch
|
|
2351
|
+
* - **Patch.** `patch` applies a validated {@link PhaseUpdate} to self
|
|
1903
2352
|
* (`name` / `description` / `concurrency` / `bail`) — defense-in-depth: it throws a
|
|
1904
2353
|
* `MUTATION` {@link WorkflowError} unless this phase's own `status` is `pending`, mirroring
|
|
1905
2354
|
* the owning {@link WorkflowInterface.update}'s gate.
|
|
1906
|
-
* - **Minting
|
|
1907
|
-
* (converts it to a {@link TaskSnapshot}, builds the task wired to
|
|
2355
|
+
* - **Minting.** {@link add} mints a live {@link Task} from a {@link TaskDefinition}
|
|
2356
|
+
* (converts it to a {@link TaskSnapshot}, builds the task wired to this phase) — the same
|
|
1908
2357
|
* construction path {@link #append} uses at build time, so a live mint and a restored/built
|
|
1909
|
-
* task are wired
|
|
1910
|
-
* {@link import('../types.js').
|
|
1911
|
-
* {@link import('../types.js').WorkflowOptions.functions}) resolves every unique initial `
|
|
1912
|
-
* name
|
|
2358
|
+
* task are wired identically. At construction, the workflow-level
|
|
2359
|
+
* {@link import('../types.js').WorkflowRegistry} registry (threaded from
|
|
2360
|
+
* {@link import('../types.js').WorkflowOptions.functions}) resolves every unique initial `behavior`
|
|
2361
|
+
* name once before any task is built; siblings sharing a name receive the exact same captured
|
|
1913
2362
|
* runtime {@link import('../types.js').TaskInterface.handler}. A later live {@link add} reads
|
|
1914
2363
|
* that name once from the retained registry at its own mint moment. An omitted or unregistered
|
|
1915
|
-
* `
|
|
2364
|
+
* `behavior` resolves to no handler; only omission is a no-op, while an unresolved present name makes
|
|
1916
2365
|
* the containing tree non-drivable.
|
|
1917
|
-
* - **Runtime lifecycle
|
|
2366
|
+
* - **Runtime lifecycle.** `pause` / `resume` / `wait` mirror the workflow's own
|
|
1918
2367
|
* quartet, scoped to this phase — a driving
|
|
1919
2368
|
* {@link import('../types.js').WorkflowRunnerInterface.execute} gates a task's own
|
|
1920
|
-
* pre-dispatch on the workflow's gate
|
|
2369
|
+
* pre-dispatch on the workflow's gate first, then this phase's gate, without touching
|
|
1921
2370
|
* {@link status} — `paused` is runtime-only, never persisted. `skip` / `stop` (this phase's
|
|
1922
2371
|
* own terminal forcing) always release a parked {@link wait} waiter, mirroring
|
|
1923
2372
|
* {@link import('../Workflow.js').Workflow.destroy}'s cascade — a permanently-ended phase
|
|
@@ -1926,6 +2375,7 @@ var TaskManager = class {
|
|
|
1926
2375
|
var Phase = class {
|
|
1927
2376
|
#id;
|
|
1928
2377
|
#name;
|
|
2378
|
+
#description;
|
|
1929
2379
|
#workflow;
|
|
1930
2380
|
#escalateUp;
|
|
1931
2381
|
#tasks = new TaskManager();
|
|
@@ -1944,16 +2394,13 @@ var Phase = class {
|
|
|
1944
2394
|
const tasks = options?.tasks;
|
|
1945
2395
|
this.#id = snapshot.id;
|
|
1946
2396
|
this.#name = snapshot.name;
|
|
1947
|
-
|
|
1948
|
-
configurable: true,
|
|
1949
|
-
value: snapshot.description
|
|
1950
|
-
});
|
|
2397
|
+
this.#description = snapshot.description;
|
|
1951
2398
|
this.#workflow = workflow;
|
|
1952
2399
|
this.#escalateUp = escalate;
|
|
1953
2400
|
this.#functions = functions;
|
|
1954
2401
|
this.#silence = silence;
|
|
1955
2402
|
const handlers = /* @__PURE__ */ new Map();
|
|
1956
|
-
for (const task of snapshot.tasks) if (task.
|
|
2403
|
+
for (const task of snapshot.tasks) if (task.behavior !== void 0 && !handlers.has(task.behavior)) handlers.set(task.behavior, functions?.[task.behavior]);
|
|
1957
2404
|
this.#bail = bail ?? snapshot.bail;
|
|
1958
2405
|
this.#concurrency = snapshot.concurrency;
|
|
1959
2406
|
this.#emitter = new Emitter({
|
|
@@ -1962,7 +2409,7 @@ var Phase = class {
|
|
|
1962
2409
|
});
|
|
1963
2410
|
for (const task of snapshot.tasks) {
|
|
1964
2411
|
const taskOptions = tasks?.[task.id];
|
|
1965
|
-
const handler = task.
|
|
2412
|
+
const handler = task.behavior === void 0 ? void 0 : handlers.get(task.behavior);
|
|
1966
2413
|
this.#append(task, taskOptions, handler);
|
|
1967
2414
|
}
|
|
1968
2415
|
this.#override = snapshot.override;
|
|
@@ -1979,11 +2426,14 @@ var Phase = class {
|
|
|
1979
2426
|
get name() {
|
|
1980
2427
|
return this.#name;
|
|
1981
2428
|
}
|
|
2429
|
+
get description() {
|
|
2430
|
+
return this.#description;
|
|
2431
|
+
}
|
|
1982
2432
|
get context() {
|
|
1983
2433
|
return buildPhaseContext(this.#workflow.context, {
|
|
1984
2434
|
id: this.#id,
|
|
1985
2435
|
name: this.#name,
|
|
1986
|
-
...this
|
|
2436
|
+
...this.#description === void 0 ? {} : { description: this.#description }
|
|
1987
2437
|
});
|
|
1988
2438
|
}
|
|
1989
2439
|
get workflow() {
|
|
@@ -2025,7 +2475,7 @@ var Phase = class {
|
|
|
2025
2475
|
pause() {
|
|
2026
2476
|
if (this.#paused || isTerminalStatus(this.status)) return;
|
|
2027
2477
|
this.#paused = true;
|
|
2028
|
-
this.#gate =
|
|
2478
|
+
this.#gate = Promise.withResolvers();
|
|
2029
2479
|
this.#emitter.emit("pause");
|
|
2030
2480
|
}
|
|
2031
2481
|
resume() {
|
|
@@ -2087,10 +2537,7 @@ var Phase = class {
|
|
|
2087
2537
|
status: this.status
|
|
2088
2538
|
});
|
|
2089
2539
|
if (value.name !== void 0) this.#name = value.name;
|
|
2090
|
-
if (value.description !== void 0)
|
|
2091
|
-
configurable: true,
|
|
2092
|
-
value: value.description
|
|
2093
|
-
});
|
|
2540
|
+
if (value.description !== void 0) this.#description = value.description;
|
|
2094
2541
|
if (value.concurrency !== void 0) this.#concurrency = value.concurrency;
|
|
2095
2542
|
if (value.bail !== void 0) this.#bail = value.bail;
|
|
2096
2543
|
}
|
|
@@ -2098,7 +2545,7 @@ var Phase = class {
|
|
|
2098
2545
|
return {
|
|
2099
2546
|
id: this.id,
|
|
2100
2547
|
name: this.name,
|
|
2101
|
-
...this
|
|
2548
|
+
...this.#description === void 0 ? {} : { description: this.#description },
|
|
2102
2549
|
status: this.status,
|
|
2103
2550
|
...this.#override === void 0 ? {} : { override: this.#override },
|
|
2104
2551
|
bail: this.#bail,
|
|
@@ -2133,7 +2580,7 @@ var Phase = class {
|
|
|
2133
2580
|
}
|
|
2134
2581
|
#failure() {
|
|
2135
2582
|
const found = findFailure(this.results());
|
|
2136
|
-
if (found === void 0) throw new
|
|
2583
|
+
if (found === void 0) throw new WorkflowError("INVARIANT", `phase '${this.id}' derived failed with no failing task result`, { phase: this.id });
|
|
2137
2584
|
return found;
|
|
2138
2585
|
}
|
|
2139
2586
|
#release() {
|
|
@@ -2151,11 +2598,11 @@ var Phase = class {
|
|
|
2151
2598
|
this.#tasks.append(created);
|
|
2152
2599
|
}
|
|
2153
2600
|
#create(snapshot, options, handler) {
|
|
2154
|
-
return new Task(buildTaskContext(this.context, snapshot), this, this.#workflow, () => this.#recompute(), options, snapshot.status, snapshot.result, snapshot.
|
|
2601
|
+
return new Task(buildTaskContext(this.context, snapshot), this, this.#workflow, () => this.#recompute(), options, snapshot.status, snapshot.result, snapshot.behavior, snapshot.retries, snapshot.timeout, snapshot.metadata, snapshot.attempts, snapshot.activity, handler, this.#silence);
|
|
2155
2602
|
}
|
|
2156
2603
|
#mint(definition) {
|
|
2157
2604
|
const snapshot = taskDefinitionToSnapshot(definition);
|
|
2158
|
-
const handler = snapshot.
|
|
2605
|
+
const handler = snapshot.behavior === void 0 ? void 0 : this.#functions?.[snapshot.behavior];
|
|
2159
2606
|
return this.#create(snapshot, void 0, handler);
|
|
2160
2607
|
}
|
|
2161
2608
|
#statuses() {
|
|
@@ -2165,24 +2612,28 @@ var Phase = class {
|
|
|
2165
2612
|
//#endregion
|
|
2166
2613
|
//#region src/core/phases/PhaseManager.ts
|
|
2167
2614
|
/**
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2615
|
+
* Implements the lean child manager of a {@link import('../Workflow.js').Workflow}'s live
|
|
2616
|
+
* phases — the phase vocabulary over one insertion-ordered {@link Collection}, the phase analogue
|
|
2170
2617
|
* of {@link import('../tasks/TaskManager.js').TaskManager}.
|
|
2171
2618
|
*
|
|
2172
2619
|
* @remarks
|
|
2173
|
-
* - **
|
|
2174
|
-
* `
|
|
2175
|
-
*
|
|
2176
|
-
*
|
|
2177
|
-
* - **
|
|
2178
|
-
*
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
2184
|
-
*
|
|
2185
|
-
*
|
|
2620
|
+
* - **One shared store.** The insertion-ordered `Map`, the reorder step, the bounds checks, and
|
|
2621
|
+
* the gated `add` / `remove` / `move` / `update` all live in {@link Collection}, built with the
|
|
2622
|
+
* `phase` noun its refusals name and the compiled {@link phaseUpdateShape} guard. This class
|
|
2623
|
+
* adds the domain accessors `phase` / `phases` and nothing else.
|
|
2624
|
+
* - **Positional store.** `append` adds one live {@link PhaseInterface} at the end, `phase(id)`
|
|
2625
|
+
* looks one up, `phases()` lists them in positional order, `count` is the tally. A snapshot
|
|
2626
|
+
* RESTORE re-`append`s in the snapshot's order, reproducing it exactly.
|
|
2627
|
+
* - **Gated mutation API.** `add` / `remove` / `move` / `update` are the graceful
|
|
2628
|
+
* `Result` counterparts to `append`, gating only on the target's own existence/status/id/bounds
|
|
2629
|
+
* — a duplicate id, an absent/non-`pending` target, an out-of-bounds `index`, or a patch that
|
|
2630
|
+
* fails {@link phaseUpdateShape} validation all fail gracefully with a `MUTATION`
|
|
2631
|
+
* {@link WorkflowError} instead of throwing.
|
|
2632
|
+
* - **No batch matrix.** A workflow's phases are a fixed positional set, so the batch verbs of
|
|
2633
|
+
* `.claude/rules/patterns.md` § Batch operations are
|
|
2634
|
+
* deliberately omitted.
|
|
2635
|
+
* - **Event-free.** A purely structural container — the live {@link PhaseInterface}s own their own
|
|
2636
|
+
* emitters.
|
|
2186
2637
|
*
|
|
2187
2638
|
* @example
|
|
2188
2639
|
* ```ts
|
|
@@ -2193,58 +2644,37 @@ var Phase = class {
|
|
|
2193
2644
|
* ```
|
|
2194
2645
|
*/
|
|
2195
2646
|
var PhaseManager = class {
|
|
2196
|
-
#phases =
|
|
2197
|
-
#isUpdate = compileGuard(phaseUpdateShape);
|
|
2647
|
+
#phases = new Collection("phase", compileGuard(phaseUpdateShape));
|
|
2198
2648
|
get count() {
|
|
2199
|
-
return this.#phases.
|
|
2649
|
+
return this.#phases.count;
|
|
2200
2650
|
}
|
|
2201
2651
|
append(phase) {
|
|
2202
|
-
|
|
2203
|
-
this.#phases.set(phase.id, phase);
|
|
2652
|
+
this.#phases.append(phase);
|
|
2204
2653
|
}
|
|
2205
2654
|
add(phase, index) {
|
|
2206
|
-
|
|
2207
|
-
const at = index ?? this.#phases.size;
|
|
2208
|
-
if (at < 0 || at > this.#phases.size) return failure(new WorkflowError("MUTATION", `index '${at}' out of bounds`, { index: at }));
|
|
2209
|
-
this.#reorder(insertEntry([...this.#phases.entries()], at, phase.id, phase));
|
|
2210
|
-
return success(phase);
|
|
2655
|
+
return this.#phases.add(phase, index);
|
|
2211
2656
|
}
|
|
2212
2657
|
remove(id) {
|
|
2213
|
-
|
|
2214
|
-
if (target === void 0 || target.status !== "pending") return failure(new WorkflowError("MUTATION", `phase '${id}' is not a pending phase`, { id }));
|
|
2215
|
-
this.#phases.delete(id);
|
|
2216
|
-
return success(target);
|
|
2658
|
+
return this.#phases.remove(id);
|
|
2217
2659
|
}
|
|
2218
2660
|
move(id, index) {
|
|
2219
|
-
|
|
2220
|
-
if (target === void 0 || target.status !== "pending") return failure(new WorkflowError("MUTATION", `phase '${id}' is not a pending phase`, { id }));
|
|
2221
|
-
if (index < 0 || index >= this.#phases.size) return failure(new WorkflowError("MUTATION", `index '${index}' out of bounds`, { index }));
|
|
2222
|
-
this.#reorder(moveEntry([...this.#phases.entries()], id, index));
|
|
2223
|
-
return success(target);
|
|
2661
|
+
return this.#phases.move(id, index);
|
|
2224
2662
|
}
|
|
2225
2663
|
update(id, patch) {
|
|
2226
|
-
|
|
2227
|
-
if (target === void 0 || target.status !== "pending") return failure(new WorkflowError("MUTATION", `phase '${id}' is not a pending phase`, { id }));
|
|
2228
|
-
if (!this.#isUpdate(patch)) return failure(new WorkflowError("MUTATION", `invalid patch for phase '${id}'`, { id }));
|
|
2229
|
-
target.patch(patch);
|
|
2230
|
-
return success(target);
|
|
2664
|
+
return this.#phases.update(id, patch);
|
|
2231
2665
|
}
|
|
2232
2666
|
phase(id) {
|
|
2233
|
-
return this.#phases.
|
|
2667
|
+
return this.#phases.entry(id);
|
|
2234
2668
|
}
|
|
2235
2669
|
phases() {
|
|
2236
|
-
return
|
|
2237
|
-
}
|
|
2238
|
-
#reorder(entries) {
|
|
2239
|
-
this.#phases.clear();
|
|
2240
|
-
for (const [key, value] of entries) this.#phases.set(key, value);
|
|
2670
|
+
return this.#phases.entries();
|
|
2241
2671
|
}
|
|
2242
2672
|
};
|
|
2243
2673
|
//#endregion
|
|
2244
2674
|
//#region src/core/Workflow.ts
|
|
2245
2675
|
/**
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
2676
|
+
* Implements the live derived state machine (W-b) for a whole workflow — the observable root
|
|
2677
|
+
* whose {@link LifecycleStatus} is computed from its phases under the `bail` policy and
|
|
2248
2678
|
* recomputed reactively as the cascade propagates up from a task transition.
|
|
2249
2679
|
*
|
|
2250
2680
|
* @remarks
|
|
@@ -2254,38 +2684,53 @@ var PhaseManager = class {
|
|
|
2254
2684
|
* passes a persisted one). Each child {@link Phase} is wired to escalate to `#recompute`.
|
|
2255
2685
|
* - **Derived status.** `status` is `#override` when forced, else
|
|
2256
2686
|
* {@link deriveWorkflowStatus} over the live phases' statuses feeding `bail`. `failed` is
|
|
2257
|
-
* reachable
|
|
2687
|
+
* reachable only under `bail: true` (a single failed task halts the workflow); under
|
|
2258
2688
|
* `bail: false` a failed phase folds into `completed`. `#recompute` diffs on each phase
|
|
2259
|
-
* change; a
|
|
2260
|
-
* - **Override
|
|
2261
|
-
* may also be force-completed vacuously. The override is
|
|
2262
|
-
* `override` field and restored
|
|
2689
|
+
* change; a change emits.
|
|
2690
|
+
* - **Override.** `skip` / `stop` force the status; an executed task-free pending tree
|
|
2691
|
+
* may also be force-completed vacuously. The override is persisted in the snapshot's own
|
|
2692
|
+
* `override` field and restored directly (no divergence guess). The snapshot also persists
|
|
2263
2693
|
* `bail`, so a restore re-derives status identically without a silent policy default.
|
|
2264
2694
|
* - **Result tree.** `results()` flattens every phase's `results()` ({@link collectResults}) — the
|
|
2265
|
-
* workflow tier; `phase(id)` + each `phase.task(id)` navigate
|
|
2266
|
-
* navigate
|
|
2695
|
+
* workflow tier; `phase(id)` + each `phase.task(id)` navigate down, a task's `phase` / `workflow`
|
|
2696
|
+
* navigate up.
|
|
2267
2697
|
* - **Snapshot.** `snapshot()` serializes the whole live tree to a {@link WorkflowSnapshot} (pure
|
|
2268
2698
|
* JSON); {@link import('./factories.js').createRestoredWorkflow} rebuilds an equivalent live tree.
|
|
2269
|
-
* - **Observable
|
|
2699
|
+
* - **Observable.** The owned {@link emitter} ({@link WorkflowEventMap}) fires
|
|
2270
2700
|
* `start` / `complete` / `fail` / `pause` / `resume` / `skip` / `stop` after the
|
|
2271
2701
|
* corresponding status or runtime-gate change; the emitter isolates a listener throw and
|
|
2272
2702
|
* routes it to its `error` handler (the `error` option); `fail` carries the failing task's
|
|
2273
2703
|
* {@link TaskResult}.
|
|
2274
|
-
* - **Structural API
|
|
2704
|
+
* - **Structural API.** `add` / `remove` / `move` / `update` gate before
|
|
2275
2705
|
* delegating to {@link phases} (the manager gates the target's own existence/status/id/
|
|
2276
|
-
* bounds), then emit the matching {@link WorkflowEventMap} event on success only.
|
|
2706
|
+
* bounds), then emit the matching {@link WorkflowEventMap} event on success only. Native,
|
|
2277
2707
|
* bottom-up gating (no runner-installed hook): refused outright while this workflow's own
|
|
2278
|
-
* `status` is terminal; otherwise a target position must fall within the
|
|
2708
|
+
* `status` is terminal; otherwise a target position must fall within the pending suffix —
|
|
2279
2709
|
* the contiguous trailing run of `pending` phases — whose boundary is
|
|
2280
2710
|
* {@link import('./helpers.js').deriveBoundary} over the live phases' statuses. A `pending`
|
|
2281
2711
|
* workflow's phases are all `pending`, so the boundary is `0` and every position is
|
|
2282
2712
|
* naturally accepted.
|
|
2283
|
-
* - **Runtime lifecycle
|
|
2284
|
-
* phase/task boundaries
|
|
2713
|
+
* - **Runtime lifecycle.** `pause` / `resume` / `wait` gate execution at the runner's
|
|
2714
|
+
* phase/task boundaries without touching {@link status} — `paused` is runtime-only, never
|
|
2285
2715
|
* persisted. `destroy` is a terminal teardown: it `stop`s every non-terminal task and
|
|
2286
2716
|
* phase (releasing their gates and liveness resources), aborts {@link signal}, forces the
|
|
2287
2717
|
* workflow `stop` override when needed, releases its parked waiter, and marks
|
|
2288
2718
|
* {@link destroyed} — all idempotent.
|
|
2719
|
+
*
|
|
2720
|
+
* @example
|
|
2721
|
+
* ```ts
|
|
2722
|
+
* import { definitionToSnapshot, Workflow } from '@orkestrel/workflow'
|
|
2723
|
+
*
|
|
2724
|
+
* const definition = {
|
|
2725
|
+
* id: 'release',
|
|
2726
|
+
* name: 'Release',
|
|
2727
|
+
* phases: [{ id: 'build', name: 'Build', tasks: [{ id: 'compile', name: 'Compile' }] }],
|
|
2728
|
+
* }
|
|
2729
|
+
* const workflow = new Workflow(definitionToSnapshot(definition))
|
|
2730
|
+
* workflow.status // 'pending'
|
|
2731
|
+
* workflow.phase('build')?.task('compile')?.status // 'pending'
|
|
2732
|
+
* workflow.snapshot().id // 'release'
|
|
2733
|
+
* ```
|
|
2289
2734
|
*/
|
|
2290
2735
|
var Workflow = class {
|
|
2291
2736
|
#context;
|
|
@@ -2312,7 +2757,6 @@ var Workflow = class {
|
|
|
2312
2757
|
const functions = captured.functions;
|
|
2313
2758
|
const silence = captured.silence;
|
|
2314
2759
|
this.#context = buildWorkflowContext(snapshot);
|
|
2315
|
-
if (snapshot.description !== void 0) Object.defineProperty(this, "description", { value: snapshot.description });
|
|
2316
2760
|
this.#bail = bail ?? snapshot.bail;
|
|
2317
2761
|
this.#bailOverride = bail;
|
|
2318
2762
|
this.#functions = functions;
|
|
@@ -2343,6 +2787,9 @@ var Workflow = class {
|
|
|
2343
2787
|
get name() {
|
|
2344
2788
|
return this.#context.name;
|
|
2345
2789
|
}
|
|
2790
|
+
get description() {
|
|
2791
|
+
return this.#context.description;
|
|
2792
|
+
}
|
|
2346
2793
|
get context() {
|
|
2347
2794
|
return this.#context;
|
|
2348
2795
|
}
|
|
@@ -2386,7 +2833,7 @@ var Workflow = class {
|
|
|
2386
2833
|
pause() {
|
|
2387
2834
|
if (this.#paused || isTerminalStatus(this.status) || this.#destroyed) return;
|
|
2388
2835
|
this.#paused = true;
|
|
2389
|
-
this.#gate =
|
|
2836
|
+
this.#gate = Promise.withResolvers();
|
|
2390
2837
|
this.#emitter.emit("pause");
|
|
2391
2838
|
}
|
|
2392
2839
|
resume() {
|
|
@@ -2474,7 +2921,7 @@ var Workflow = class {
|
|
|
2474
2921
|
return cloneWorkflowSnapshot({
|
|
2475
2922
|
id: this.id,
|
|
2476
2923
|
name: this.name,
|
|
2477
|
-
...this.description === void 0 ? {} : { description: this.description },
|
|
2924
|
+
...this.#context.description === void 0 ? {} : { description: this.#context.description },
|
|
2478
2925
|
status: this.status,
|
|
2479
2926
|
...this.#override === void 0 ? {} : { override: this.#override },
|
|
2480
2927
|
bail: this.#bail,
|
|
@@ -2518,7 +2965,7 @@ var Workflow = class {
|
|
|
2518
2965
|
}
|
|
2519
2966
|
#failure() {
|
|
2520
2967
|
const found = findFailure(this.results());
|
|
2521
|
-
if (found === void 0) throw new
|
|
2968
|
+
if (found === void 0) throw new WorkflowError("INVARIANT", `workflow '${this.id}' derived failed with no failing task result`, { workflow: this.id });
|
|
2522
2969
|
return found;
|
|
2523
2970
|
}
|
|
2524
2971
|
#append(phase, options) {
|
|
@@ -2543,24 +2990,25 @@ var Workflow = class {
|
|
|
2543
2990
|
//#endregion
|
|
2544
2991
|
//#region src/core/WorkflowManager.ts
|
|
2545
2992
|
/**
|
|
2546
|
-
*
|
|
2993
|
+
* Implements the store-backed registry of {@link WorkflowInterface}s keyed by `id`, in insertion order —
|
|
2547
2994
|
* the additive manager tier mirroring the `@orkestrel/agent` line's `ConversationManager` /
|
|
2548
2995
|
* `WorkspaceManager`. Event-free (a registry, like its twins); the observability lives on each
|
|
2549
2996
|
* {@link WorkflowInterface}.
|
|
2550
2997
|
*
|
|
2551
2998
|
* @remarks
|
|
2552
2999
|
* - **Registry.** Workflows live in an insertion-ordered `Map` keyed by `id`. `add(definition)`
|
|
2553
|
-
* mints a live {@link WorkflowInterface} through
|
|
3000
|
+
* mints a live {@link WorkflowInterface} through the same construction path
|
|
3001
|
+
* {@link import('./factories.js').createWorkflow} takes (flowing the manager's
|
|
2554
3002
|
* `functions` registry in) and stores it under `definition.id` — an already-present id
|
|
2555
|
-
*
|
|
3003
|
+
* overwrites (last write wins). `count` is the map size, `workflow(id)` looks one up,
|
|
2556
3004
|
* `workflows()` lists them in insertion order.
|
|
2557
3005
|
* - **Durable open / save.** `open(id)` returns an already-registered workflow directly; same-id
|
|
2558
3006
|
* misses share one hydration. A concurrent `add` wins, while `remove` / `clear` invalidate
|
|
2559
3007
|
* earlier reads; wrong-key payloads reject with `RESTORE`. `save(id)` captures a registered
|
|
2560
3008
|
* workflow's snapshot at invocation and serializes same-id writes without coupling other ids.
|
|
2561
3009
|
* Both remain lenient without a store or registered id.
|
|
2562
|
-
* - **Removal.** `remove` drops one by id, or a batch (
|
|
2563
|
-
*
|
|
3010
|
+
* - **Removal.** `remove` drops one by id, or a batch (array overload first) — `true` only when
|
|
3011
|
+
* every id was removed. `clear` empties the registry.
|
|
2564
3012
|
* - **No active pointer.** Unlike its `ConversationManager` / `WorkspaceManager` twins, there is
|
|
2565
3013
|
* no `active` / `switch` — nothing in the workflow domain renders "the current workflow".
|
|
2566
3014
|
*
|
|
@@ -2569,7 +3017,7 @@ var Workflow = class {
|
|
|
2569
3017
|
* const manager = new WorkflowManager({
|
|
2570
3018
|
* functions: { compile: async (controller) => `built ${controller.task.id}` },
|
|
2571
3019
|
* })
|
|
2572
|
-
* const workflow = manager.add(definition) // minted, registered,
|
|
3020
|
+
* const workflow = manager.add(definition) // minted, registered, runnable
|
|
2573
3021
|
* manager.workflow(workflow.id) // the same workflow
|
|
2574
3022
|
* manager.count // 1
|
|
2575
3023
|
* ```
|
|
@@ -2598,7 +3046,7 @@ var WorkflowManager = class {
|
|
|
2598
3046
|
return [...this.#workflows.values()];
|
|
2599
3047
|
}
|
|
2600
3048
|
add(definition) {
|
|
2601
|
-
const workflow =
|
|
3049
|
+
const workflow = this.#build(definition);
|
|
2602
3050
|
const mutation = this.#invalidate(workflow.id);
|
|
2603
3051
|
if (mutation === void 0) this.#additions.delete(workflow.id);
|
|
2604
3052
|
else this.#additions.set(workflow.id, mutation);
|
|
@@ -2635,11 +3083,11 @@ var WorkflowManager = class {
|
|
|
2635
3083
|
}
|
|
2636
3084
|
remove(ids) {
|
|
2637
3085
|
if (isArray(ids)) {
|
|
2638
|
-
let removed =
|
|
3086
|
+
let removed = true;
|
|
2639
3087
|
for (const id of ids) {
|
|
2640
3088
|
this.#invalidate(id);
|
|
2641
3089
|
this.#additions.delete(id);
|
|
2642
|
-
if (this.#workflows.delete(id)) removed =
|
|
3090
|
+
if (!this.#workflows.delete(id)) removed = false;
|
|
2643
3091
|
}
|
|
2644
3092
|
return removed;
|
|
2645
3093
|
}
|
|
@@ -2669,7 +3117,7 @@ var WorkflowManager = class {
|
|
|
2669
3117
|
if (!this.#owns(id, mutation, generation)) return this.#resolve(id, generation);
|
|
2670
3118
|
let workflow;
|
|
2671
3119
|
try {
|
|
2672
|
-
workflow =
|
|
3120
|
+
workflow = this.#restore(owned);
|
|
2673
3121
|
} catch (error) {
|
|
2674
3122
|
if (!this.#owns(id, mutation, generation)) return this.#resolve(id, generation);
|
|
2675
3123
|
throw error;
|
|
@@ -2680,6 +3128,15 @@ var WorkflowManager = class {
|
|
|
2680
3128
|
this.#releaseHydration(id, lease);
|
|
2681
3129
|
}
|
|
2682
3130
|
}
|
|
3131
|
+
#build(definition) {
|
|
3132
|
+
return createWorkflowTree(definition, this.#captured());
|
|
3133
|
+
}
|
|
3134
|
+
#restore(snapshot) {
|
|
3135
|
+
return new Workflow(cloneWorkflowSnapshot(snapshot), this.#captured());
|
|
3136
|
+
}
|
|
3137
|
+
#captured() {
|
|
3138
|
+
return captureWorkflowOptions(this.#functions === void 0 ? {} : { functions: this.#functions });
|
|
3139
|
+
}
|
|
2683
3140
|
#owns(id, mutation, generation) {
|
|
2684
3141
|
return this.#generation === generation && this.#mutations.get(id) === mutation;
|
|
2685
3142
|
}
|
|
@@ -2735,9 +3192,50 @@ var WorkflowManager = class {
|
|
|
2735
3192
|
}
|
|
2736
3193
|
};
|
|
2737
3194
|
//#endregion
|
|
3195
|
+
//#region src/core/RunHolder.ts
|
|
3196
|
+
/**
|
|
3197
|
+
* Holds the active phase {@link RunnerInterface} for one
|
|
3198
|
+
* {@link import('./types.js').WorkflowRunnerInterface.execute} call, for the lifetime of that run.
|
|
3199
|
+
*
|
|
3200
|
+
* @remarks
|
|
3201
|
+
* - **One holder per run.** The engine mints a holder as a run begins and threads that one
|
|
3202
|
+
* instance through every phase of the run, so a nested `execute` reached through application
|
|
3203
|
+
* composition gets its own holder and can never clobber the suspended outer run's.
|
|
3204
|
+
* - **`hold` is the only mutation.** A phase takes the substrate runner with `hold(runner)` as it
|
|
3205
|
+
* starts and releases it with `hold()` as it settles; `runner` reads the held value back and is
|
|
3206
|
+
* `undefined` between phases and after the last one.
|
|
3207
|
+
* - **A cancel closes over the holder.** The run-level abort listener reads `runner` when it
|
|
3208
|
+
* fires, so it reaches whichever phase runner is live at that moment rather than the one that
|
|
3209
|
+
* was live when the listener was armed.
|
|
3210
|
+
* - **Event-free.** A plain cell — no emitter, no lifecycle of its own.
|
|
3211
|
+
*/
|
|
3212
|
+
var RunHolder = class {
|
|
3213
|
+
#runner;
|
|
3214
|
+
get runner() {
|
|
3215
|
+
return this.#runner;
|
|
3216
|
+
}
|
|
3217
|
+
/**
|
|
3218
|
+
* Takes the phase runner a starting phase hands this run, or releases the held one.
|
|
3219
|
+
*
|
|
3220
|
+
* @param runner - The phase runner to hold; omitted releases the held runner
|
|
3221
|
+
* @example
|
|
3222
|
+
* ```ts
|
|
3223
|
+
* import type { TaskInterface } from '@orkestrel/workflow'
|
|
3224
|
+
* import { createRunner, RunHolder } from '@orkestrel/workflow'
|
|
3225
|
+
*
|
|
3226
|
+
* const holder = new RunHolder()
|
|
3227
|
+
* holder.hold(createRunner<TaskInterface, void>({ handler: () => undefined }))
|
|
3228
|
+
* holder.hold() // released — `runner` reads `undefined` again
|
|
3229
|
+
* ```
|
|
3230
|
+
*/
|
|
3231
|
+
hold(runner) {
|
|
3232
|
+
this.#runner = runner;
|
|
3233
|
+
}
|
|
3234
|
+
};
|
|
3235
|
+
//#endregion
|
|
2738
3236
|
//#region src/core/Controller.ts
|
|
2739
3237
|
/**
|
|
2740
|
-
*
|
|
3238
|
+
* Implements the per-unit handle a runner handler receives — wraps the unit's identity,
|
|
2741
3239
|
* input, cancellation, and the run controls (`wait` / `spawn` / `abort`).
|
|
2742
3240
|
*
|
|
2743
3241
|
* @remarks
|
|
@@ -2745,14 +3243,14 @@ var WorkflowManager = class {
|
|
|
2745
3243
|
* unit it dispatches, handing it the unit's `id`, `input`, the unit's `Abort`
|
|
2746
3244
|
* handle, the queue attempt's `signal`, and a `spawn` callback that launches a
|
|
2747
3245
|
* sibling through the same queue.
|
|
2748
|
-
* - **Signal.** `signal` is the queue attempt's signal, which
|
|
3246
|
+
* - **Signal.** `signal` is the queue attempt's signal, which any-combines the
|
|
2749
3247
|
* unit's own abort, the runner-level abort (the runner aborts every unit), and
|
|
2750
3248
|
* the per-attempt timeout — so it fires on any of the three. `aborted` and
|
|
2751
3249
|
* `abort(reason)` delegate to the unit's `Abort` (the cancellation source of
|
|
2752
|
-
* truth);
|
|
3250
|
+
* truth); because the attempt signal any-includes that abort, `abort()` fires
|
|
2753
3251
|
* `signal` too.
|
|
2754
3252
|
* - **`wait` promise-parks (never a timer).** It resolves the instant the unit's
|
|
2755
|
-
* `signal` fires (immediately if already aborted)
|
|
3253
|
+
* `signal` fires (immediately if already aborted) through a one-shot listener — no
|
|
2756
3254
|
* `setTimeout`, no polling, no busy-yield — so a parked unit costs no CPU.
|
|
2757
3255
|
* - **`spawn` is fire-and-track.** It delegates to the runner's launch-a-sibling
|
|
2758
3256
|
* callback, which routes the sibling through the queue; the runner's `execute`
|
|
@@ -2763,23 +3261,32 @@ var WorkflowManager = class {
|
|
|
2763
3261
|
* {@link RunnerInterface.emitter} instead (`unit` / `spawn` / `settle` / `fail` carry the id).
|
|
2764
3262
|
*/
|
|
2765
3263
|
var Controller = class {
|
|
2766
|
-
id;
|
|
2767
|
-
input;
|
|
2768
|
-
signal;
|
|
3264
|
+
#id;
|
|
3265
|
+
#input;
|
|
3266
|
+
#signal;
|
|
2769
3267
|
#abort;
|
|
2770
3268
|
#spawn;
|
|
2771
3269
|
constructor(id, input, abort, signal, spawn) {
|
|
2772
|
-
this
|
|
2773
|
-
this
|
|
3270
|
+
this.#id = id;
|
|
3271
|
+
this.#input = input;
|
|
2774
3272
|
this.#abort = abort;
|
|
2775
|
-
this
|
|
3273
|
+
this.#signal = signal;
|
|
2776
3274
|
this.#spawn = spawn;
|
|
2777
3275
|
}
|
|
3276
|
+
get id() {
|
|
3277
|
+
return this.#id;
|
|
3278
|
+
}
|
|
3279
|
+
get input() {
|
|
3280
|
+
return this.#input;
|
|
3281
|
+
}
|
|
3282
|
+
get signal() {
|
|
3283
|
+
return this.#signal;
|
|
3284
|
+
}
|
|
2778
3285
|
get aborted() {
|
|
2779
3286
|
return this.#abort.aborted;
|
|
2780
3287
|
}
|
|
2781
3288
|
wait() {
|
|
2782
|
-
return parkSignal(this
|
|
3289
|
+
return parkSignal(this.#signal);
|
|
2783
3290
|
}
|
|
2784
3291
|
spawn(input) {
|
|
2785
3292
|
return this.#spawn(input);
|
|
@@ -2791,7 +3298,7 @@ var Controller = class {
|
|
|
2791
3298
|
//#endregion
|
|
2792
3299
|
//#region src/core/Runner.ts
|
|
2793
3300
|
/**
|
|
2794
|
-
*
|
|
3301
|
+
* Implements a thin generic orchestrator that drives declared units — and any they `spawn` —
|
|
2795
3302
|
* through a bounded-concurrency {@link createQueue}, collecting ordered results.
|
|
2796
3303
|
*
|
|
2797
3304
|
* @remarks
|
|
@@ -2800,17 +3307,17 @@ var Controller = class {
|
|
|
2800
3307
|
* bounded concurrency, retries, and the per-attempt timeout are all the Queue's —
|
|
2801
3308
|
* the Runner adds only orchestration (launching, ordering, draining, fail-fast).
|
|
2802
3309
|
* - **Spawns actually run, results stay ordered (the B2 fix).** Declared inputs and
|
|
2803
|
-
* `spawn`ed siblings flow through the
|
|
3310
|
+
* `spawn`ed siblings flow through the same `#launch`, which appends the unit's `id`
|
|
2804
3311
|
* to an ordered `#order` list and records its settled value into `#values` by `id`.
|
|
2805
3312
|
* Results are read back as `#order.map(id => #values.get(id))` — declared first (in
|
|
2806
3313
|
* input order), then spawns (in spawn order). There is no one-time task snapshot,
|
|
2807
3314
|
* so a unit spawned mid-handler is run and ordered like any other.
|
|
2808
|
-
* - **`execute` awaits the full spawn closure
|
|
2809
|
-
* an outstanding-unit `#count`
|
|
3315
|
+
* - **`execute` awaits the full spawn closure through a count gate.** `#launch` increments
|
|
3316
|
+
* an outstanding-unit `#count` before enqueuing and every settle decrements it,
|
|
2810
3317
|
* resolving the `#drained` deferred at zero. Because `spawn` calls `#launch` (so
|
|
2811
3318
|
* `#count += 1`) before the parent handler returns, the count never reaches zero
|
|
2812
3319
|
* mid-run — `execute` parks on `#drained` and so awaits the entire transitive
|
|
2813
|
-
* closure, not
|
|
3320
|
+
* closure, not only the declared units.
|
|
2814
3321
|
* - **`spawn` is fire-and-track.** A spawned unit runs through the queue regardless of
|
|
2815
3322
|
* whether its promise is awaited; the Runner never awaits a spawned promise from
|
|
2816
3323
|
* within a handler's slot (it awaits the count gate instead), so a slot-holding
|
|
@@ -2818,26 +3325,26 @@ var Controller = class {
|
|
|
2818
3325
|
* spawn by a bounded handler can still deadlock — that caveat is the caller's.)
|
|
2819
3326
|
* - **Per-unit Controller + signal.** Each unit gets a `Controller` carrying its `id`,
|
|
2820
3327
|
* `input`, the unit's `Abort` (so `aborted` / `abort` delegate to it), and the queue
|
|
2821
|
-
* attempt's `signal` (which
|
|
3328
|
+
* attempt's `signal` (which any-combines the unit abort + runner abort + timeout). A
|
|
2822
3329
|
* `spawn` callback is injected so `controller.spawn(input)` delegates to `#launch`.
|
|
2823
3330
|
* - **One-shot + fail-fast.** `execute` runs once (a second call throws). The first
|
|
2824
3331
|
* unit failure (after its retries) records the error and `abort()`s the run, so every
|
|
2825
3332
|
* sibling's signal fires; later failures are ignored and `execute` rejects with the
|
|
2826
3333
|
* first error. A user `abort(reason)` likewise rejects a running `execute`.
|
|
2827
|
-
* - **`pause` / `resume` / `stop`
|
|
2828
|
-
* delegate straight to the Queue's own pause/resume (holding/releasing the
|
|
3334
|
+
* - **`pause` / `resume` / `stop` ride the backing Queue.** `pause` / `resume`
|
|
3335
|
+
* delegate straight to the Queue's own pause/resume (holding/releasing the next
|
|
2829
3336
|
* dispatch while an in-flight unit finishes); `paused` mirrors the Queue's. `stop` is a
|
|
2830
|
-
*
|
|
2831
|
-
* units are rejected by the Queue's own stop
|
|
3337
|
+
* graceful permanent end, distinct from `abort`: still-pending (never-dispatched)
|
|
3338
|
+
* units are rejected by the Queue's own stop without their handler ever running, and
|
|
2832
3339
|
* `#settle` reads that fact (`#dispatched`) to treat the rejection as a stop artifact —
|
|
2833
3340
|
* not a failure, never tripping fail-fast — while an in-flight unit still runs to
|
|
2834
|
-
* completion and settles normally. `execute`
|
|
3341
|
+
* completion and settles normally. `execute` resolves (never rejects) after every unit
|
|
2835
3342
|
* has settled, with whatever results actually completed.
|
|
2836
|
-
* - **Observable
|
|
3343
|
+
* - **Observable.** The owned {@link emitter} ({@link RunnerEventMap}) carries the run
|
|
2837
3344
|
* lifecycle — `start` / `unit` / `spawn` / `settle` / `fail` / `finish` / `abort` — for
|
|
2838
|
-
* fire-and-forget observers. Every event is emitted directly, strictly
|
|
3345
|
+
* fire-and-forget observers. Every event is emitted directly, strictly after the relevant
|
|
2839
3346
|
* launch / settle / drain transition; the emitter isolates a listener throw and routes it
|
|
2840
|
-
* to its `error` handler (the `error` option), so a buggy observer can
|
|
3347
|
+
* to its `error` handler (the `error` option), so a buggy observer can never reorder, throw
|
|
2841
3348
|
* into, or corrupt the one-shot / fail-fast / spawn-tracking engine: the outstanding-unit
|
|
2842
3349
|
* count gate stays balanced and fail-fast still fires regardless of what a listener does.
|
|
2843
3350
|
* Observation is purely a side-channel.
|
|
@@ -2896,20 +3403,20 @@ var Runner = class {
|
|
|
2896
3403
|
return this.#queue.paused;
|
|
2897
3404
|
}
|
|
2898
3405
|
/**
|
|
2899
|
-
*
|
|
2900
|
-
* `Controller.spawn`, called from
|
|
3406
|
+
* Injects one more unit into an in-flight `execute` run — a live counterpart to a
|
|
3407
|
+
* `Controller.spawn`, called from outside any unit's handler.
|
|
2901
3408
|
*
|
|
2902
3409
|
* @remarks
|
|
2903
|
-
* Returns `undefined` synchronously (graceful, non-throwing
|
|
2904
|
-
* runner is
|
|
3410
|
+
* Returns `undefined` synchronously (graceful, non-throwing) unless the
|
|
3411
|
+
* runner is mid-`execute` and not yet stopped — covering "never started",
|
|
2905
3412
|
* "already drained", "aborted", and "destroyed". Otherwise the unit is routed through
|
|
2906
|
-
* the
|
|
2907
|
-
* unit count gate increments
|
|
3413
|
+
* the same backing queue as a declared/`spawn`ed unit through `#launch` — the outstanding-
|
|
3414
|
+
* unit count gate increments before this call returns, so an in-flight `execute`
|
|
2908
3415
|
* keeps awaiting it (the drain race: `#running` flips to `false` as the very first
|
|
2909
3416
|
* step after `execute`'s `await drained.promise` settles, so a `spawn` reaching this
|
|
2910
3417
|
* method after the run has fully drained is cleanly rejected with `undefined` —
|
|
2911
3418
|
* never silently dropped, never hangs `execute`). Emits {@link RunnerEventMap.spawn}
|
|
2912
|
-
* with a `parent` of `undefined` (this call has no spawning unit)
|
|
3419
|
+
* with a `parent` of `undefined` (this call has no spawning unit) after acceptance.
|
|
2913
3420
|
*
|
|
2914
3421
|
* @param input - The unit's work payload
|
|
2915
3422
|
* @returns The unit's result promise, or `undefined` when no in-flight run can accept it
|
|
@@ -2926,11 +3433,11 @@ var Runner = class {
|
|
|
2926
3433
|
return this.#launch(input, void 0, true);
|
|
2927
3434
|
}
|
|
2928
3435
|
async execute(inputs) {
|
|
2929
|
-
if (this.#started) throw new
|
|
2930
|
-
if (this.#stopped) throw new
|
|
3436
|
+
if (this.#started) throw new WorkflowError("TRANSITION", "runner has already executed", { started: true });
|
|
3437
|
+
if (this.#stopped) throw new WorkflowError("TRANSITION", "runner is stopped", { stopped: true });
|
|
2931
3438
|
this.#started = true;
|
|
2932
3439
|
this.#running = true;
|
|
2933
|
-
const drained =
|
|
3440
|
+
const drained = Promise.withResolvers();
|
|
2934
3441
|
this.#drained = drained;
|
|
2935
3442
|
for (const input of inputs) {
|
|
2936
3443
|
if (!this.#accepts()) break;
|
|
@@ -2951,23 +3458,23 @@ var Runner = class {
|
|
|
2951
3458
|
}
|
|
2952
3459
|
abort(reason) {
|
|
2953
3460
|
if (this.#abortPromise !== void 0) return this.#abortPromise;
|
|
2954
|
-
const barrier =
|
|
3461
|
+
const barrier = Promise.withResolvers();
|
|
2955
3462
|
this.#abortPromise = barrier.promise;
|
|
2956
3463
|
barrier.promise.catch(() => {});
|
|
2957
|
-
if (this.#running && this.#failure === void 0) this.#failure =
|
|
3464
|
+
if (this.#running && this.#failure === void 0) this.#failure = failure(reason === void 0 ? /* @__PURE__ */ new Error("runner aborted") : reason);
|
|
2958
3465
|
this.#cancel(reason);
|
|
2959
3466
|
this.#stopped = true;
|
|
2960
3467
|
const cleanup = this.#queue.abort(reason);
|
|
2961
|
-
this.#
|
|
3468
|
+
this.#settleBarrier(barrier, cleanup, false);
|
|
2962
3469
|
this.#emitter.emit("abort", reason);
|
|
2963
3470
|
return barrier.promise;
|
|
2964
3471
|
}
|
|
2965
3472
|
/**
|
|
2966
|
-
*
|
|
2967
|
-
* `pause`, which holds the
|
|
3473
|
+
* Suspends dispatch (resumable): delegates to the backing queue's own
|
|
3474
|
+
* `pause`, which holds the next dispatch while any in-flight unit finishes.
|
|
2968
3475
|
*
|
|
2969
3476
|
* @remarks
|
|
2970
|
-
* A no-op
|
|
3477
|
+
* A no-op after the runner is `stopped` — a stopped runner has no dispatch left to
|
|
2971
3478
|
* suspend, mirroring the guard `stop()` itself applies. Also a no-op when already
|
|
2972
3479
|
* `paused` (the queue's own `pause` is idempotent), so calling it repeatedly is safe.
|
|
2973
3480
|
*/
|
|
@@ -2976,11 +3483,11 @@ var Runner = class {
|
|
|
2976
3483
|
this.#queue.pause();
|
|
2977
3484
|
}
|
|
2978
3485
|
/**
|
|
2979
|
-
*
|
|
3486
|
+
* Continues a paused runner; delegates to the backing queue's `resume`.
|
|
2980
3487
|
*
|
|
2981
3488
|
* @remarks
|
|
2982
|
-
* A no-op
|
|
2983
|
-
* runner is not
|
|
3489
|
+
* A no-op after the runner is `stopped` (nothing left to resume) and a no-op when the
|
|
3490
|
+
* runner is not `paused`, so calling it repeatedly or on a never-paused
|
|
2984
3491
|
* runner is safe.
|
|
2985
3492
|
*/
|
|
2986
3493
|
resume() {
|
|
@@ -2988,10 +3495,10 @@ var Runner = class {
|
|
|
2988
3495
|
this.#queue.resume();
|
|
2989
3496
|
}
|
|
2990
3497
|
/**
|
|
2991
|
-
*
|
|
3498
|
+
* Ends the runner permanently — a graceful stop, distinct from `abort`.
|
|
2992
3499
|
* Marks the runner `stopping` + `stopped`, then stops the backing queue: every
|
|
2993
|
-
* still-
|
|
2994
|
-
* "queue is stopped" error,
|
|
3500
|
+
* still-pending (never-dispatched) unit is rejected by the queue with its own
|
|
3501
|
+
* "queue is stopped" error, without running its handler; every already-in-flight unit
|
|
2995
3502
|
* keeps running to completion and settles normally. `#settle` reads `#stopping` to
|
|
2996
3503
|
* classify a never-dispatched unit's rejection as a stop artifact (decrement the count
|
|
2997
3504
|
* gate, no recorded failure, no fail-fast trip) rather than a genuine failure — a
|
|
@@ -3001,24 +3508,24 @@ var Runner = class {
|
|
|
3001
3508
|
if (this.#destroyPromise !== void 0) return this.#destroyPromise;
|
|
3002
3509
|
if (this.#abortPromise !== void 0) return this.#abortPromise;
|
|
3003
3510
|
if (this.#stopPromise !== void 0) return this.#stopPromise;
|
|
3004
|
-
const barrier =
|
|
3511
|
+
const barrier = Promise.withResolvers();
|
|
3005
3512
|
this.#stopPromise = barrier.promise;
|
|
3006
3513
|
barrier.promise.catch(() => {});
|
|
3007
3514
|
this.#stopping = true;
|
|
3008
3515
|
this.#stopped = true;
|
|
3009
3516
|
const cleanup = this.#queue.stop();
|
|
3010
|
-
this.#
|
|
3517
|
+
this.#settleBarrier(barrier, cleanup, false);
|
|
3011
3518
|
return barrier.promise;
|
|
3012
3519
|
}
|
|
3013
3520
|
destroy() {
|
|
3014
3521
|
if (this.#destroyPromise !== void 0) return this.#destroyPromise;
|
|
3015
|
-
const barrier =
|
|
3522
|
+
const barrier = Promise.withResolvers();
|
|
3016
3523
|
this.#destroyPromise = barrier.promise;
|
|
3017
3524
|
barrier.promise.catch(() => {});
|
|
3018
3525
|
this.#stopped = true;
|
|
3019
3526
|
this.abort();
|
|
3020
3527
|
const cleanup = this.#queue.destroy();
|
|
3021
|
-
this.#
|
|
3528
|
+
this.#settleBarrier(barrier, cleanup, true);
|
|
3022
3529
|
return barrier.promise;
|
|
3023
3530
|
}
|
|
3024
3531
|
#launch(input, parent, announce = parent !== void 0) {
|
|
@@ -3046,33 +3553,27 @@ var Runner = class {
|
|
|
3046
3553
|
} catch (error) {
|
|
3047
3554
|
promise = Promise.reject(error);
|
|
3048
3555
|
}
|
|
3049
|
-
promise.then((value) => this.#settle(id,
|
|
3050
|
-
ok: true,
|
|
3051
|
-
value
|
|
3052
|
-
}), (error) => this.#settle(id, {
|
|
3053
|
-
ok: false,
|
|
3054
|
-
error
|
|
3055
|
-
}));
|
|
3556
|
+
promise.then((value) => this.#settle(id, success(value)), (error) => this.#settle(id, failure(error)));
|
|
3056
3557
|
return promise;
|
|
3057
3558
|
}
|
|
3058
|
-
#dispatch(unit,
|
|
3559
|
+
#dispatch(unit, context) {
|
|
3059
3560
|
const abort = this.#aborts.get(unit.id);
|
|
3060
|
-
if (abort === void 0) throw new
|
|
3561
|
+
if (abort === void 0) throw new WorkflowError("INVARIANT", "unit abort missing", { unit: unit.id });
|
|
3061
3562
|
this.#dispatched.add(unit.id);
|
|
3062
|
-
const controller = new Controller(unit.id, unit.input, abort,
|
|
3563
|
+
const controller = new Controller(unit.id, unit.input, abort, context.signal, (input) => this.#spawn(input, unit.id));
|
|
3063
3564
|
this.#emitter.emit("unit", unit.id);
|
|
3064
3565
|
return this.#handler(controller);
|
|
3065
3566
|
}
|
|
3066
3567
|
#spawn(input, parent) {
|
|
3067
|
-
if (!this.#accepts()) throw new
|
|
3568
|
+
if (!this.#accepts()) throw new WorkflowError("TRANSITION", "spawn is unavailable outside an active run", { parent });
|
|
3068
3569
|
return this.#launch(input, parent);
|
|
3069
3570
|
}
|
|
3070
3571
|
#settle(id, outcome) {
|
|
3071
|
-
if (outcome.
|
|
3072
|
-
this.#values.set(id,
|
|
3572
|
+
if (outcome.success) {
|
|
3573
|
+
this.#values.set(id, outcome);
|
|
3073
3574
|
this.#emitter.emit("settle", id);
|
|
3074
3575
|
} else if (this.#stopping && this.#queued.has(id) && !this.#dispatched.has(id)) {} else if (this.#failure === void 0) {
|
|
3075
|
-
this.#failure =
|
|
3576
|
+
this.#failure = failure(outcome.error);
|
|
3076
3577
|
this.#emitter.emit("fail", id, outcome.error);
|
|
3077
3578
|
this.abort(outcome.error);
|
|
3078
3579
|
}
|
|
@@ -3097,31 +3598,20 @@ var Runner = class {
|
|
|
3097
3598
|
await cleanup;
|
|
3098
3599
|
return;
|
|
3099
3600
|
} catch (error) {
|
|
3100
|
-
return
|
|
3601
|
+
return failure(error);
|
|
3101
3602
|
}
|
|
3102
3603
|
}
|
|
3103
|
-
async #
|
|
3104
|
-
let
|
|
3604
|
+
async #settleBarrier(barrier, cleanup, teardown) {
|
|
3605
|
+
let cleanupFailure;
|
|
3105
3606
|
try {
|
|
3106
3607
|
await cleanup;
|
|
3107
3608
|
} catch (error) {
|
|
3108
|
-
|
|
3609
|
+
cleanupFailure = failure(error);
|
|
3109
3610
|
}
|
|
3110
3611
|
await this.#waitDrain();
|
|
3111
|
-
if (
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
async #settleDestroy(barrier, cleanup) {
|
|
3115
|
-
let failure;
|
|
3116
|
-
try {
|
|
3117
|
-
await cleanup;
|
|
3118
|
-
} catch (error) {
|
|
3119
|
-
failure = { error };
|
|
3120
|
-
}
|
|
3121
|
-
await this.#waitDrain();
|
|
3122
|
-
this.#emitter.destroy();
|
|
3123
|
-
if (failure === void 0) barrier.resolve();
|
|
3124
|
-
else barrier.reject(failure.error);
|
|
3612
|
+
if (teardown) this.#emitter.destroy();
|
|
3613
|
+
if (cleanupFailure === void 0) barrier.resolve();
|
|
3614
|
+
else barrier.reject(cleanupFailure.error);
|
|
3125
3615
|
}
|
|
3126
3616
|
async #waitDrain() {
|
|
3127
3617
|
if (this.#count === 0) return;
|
|
@@ -3134,49 +3624,61 @@ var Runner = class {
|
|
|
3134
3624
|
//#endregion
|
|
3135
3625
|
//#region src/core/tasks/TaskController.ts
|
|
3136
3626
|
/**
|
|
3137
|
-
*
|
|
3627
|
+
* Implements the attempt-scoped handle a {@link import('./types.js').WorkflowFunction} receives.
|
|
3138
3628
|
*
|
|
3139
3629
|
* @remarks
|
|
3140
|
-
* - **A leaf handle,
|
|
3630
|
+
* - **A leaf handle, not the runner `Controller`.** A workflow task is a leaf of the
|
|
3141
3631
|
* declarative W-b tree, not a fan-out unit, so it has no `spawn`; its `wait` instead
|
|
3142
3632
|
* checkpoints the workflow, phase, and task cooperative gates.
|
|
3143
|
-
* - **Folded signal.** `signal` is the cancellation folded for
|
|
3633
|
+
* - **Folded signal.** `signal` is the cancellation folded for this attempt: its per-attempt
|
|
3144
3634
|
* deadline, task stop/skip, workflow abort/timeout/budget/destroy, or a sibling fail-fast.
|
|
3145
3635
|
* A handler races its work against it; `aborted` reads it.
|
|
3146
3636
|
* - **Attempt ownership.** `report` / `pulse` are closures supplied by the runner and refuse
|
|
3147
3637
|
* after this signal aborts or a retry token supersedes this handle.
|
|
3148
3638
|
* - **Input + lineage.** `input` is the task's open `metadata` bag (its
|
|
3149
3639
|
* {@link import('./types.js').TaskInput} payload, `{}` when none); `task` is the full
|
|
3150
|
-
* {@link TaskContext}, so `task.phase` / `task.phase.workflow` navigate
|
|
3640
|
+
* {@link TaskContext}, so `task.phase` / `task.phase.workflow` navigate up the lineage.
|
|
3151
3641
|
* - **Read-up results.** `results()` returns every settled task's {@link TaskResult} across
|
|
3152
3642
|
* the phases that have already finished (a closure over the live
|
|
3153
3643
|
* {@link import('./types.js').WorkflowInterface}), so a `function` task can read an earlier
|
|
3154
|
-
* phase's output. Read-only — a task records its
|
|
3644
|
+
* phase's output. Read-only — a task records its own outcome by returning / throwing, not
|
|
3155
3645
|
* by mutating the tree.
|
|
3156
3646
|
* - **Event-free.** Like the runner `Controller`, the per-task handle carries no Emitter;
|
|
3157
3647
|
* observe the W-b entities' own emitters (`task.emitter` / `phase.emitter`) instead.
|
|
3158
3648
|
*/
|
|
3159
3649
|
var TaskController = class {
|
|
3160
|
-
signal;
|
|
3161
|
-
input;
|
|
3162
|
-
task;
|
|
3163
|
-
attempt;
|
|
3650
|
+
#signal;
|
|
3651
|
+
#input;
|
|
3652
|
+
#task;
|
|
3653
|
+
#attempt;
|
|
3164
3654
|
#entity;
|
|
3165
3655
|
#report;
|
|
3166
3656
|
#pulse;
|
|
3167
3657
|
#results;
|
|
3168
3658
|
constructor(signal, input, task, attempt, results, report, pulse) {
|
|
3169
|
-
this
|
|
3170
|
-
this
|
|
3171
|
-
this
|
|
3172
|
-
this
|
|
3659
|
+
this.#signal = signal;
|
|
3660
|
+
this.#input = input;
|
|
3661
|
+
this.#task = task.context;
|
|
3662
|
+
this.#attempt = attempt;
|
|
3173
3663
|
this.#entity = task;
|
|
3174
3664
|
this.#results = results;
|
|
3175
3665
|
this.#report = report;
|
|
3176
3666
|
this.#pulse = pulse;
|
|
3177
3667
|
}
|
|
3668
|
+
get signal() {
|
|
3669
|
+
return this.#signal;
|
|
3670
|
+
}
|
|
3671
|
+
get input() {
|
|
3672
|
+
return this.#input;
|
|
3673
|
+
}
|
|
3674
|
+
get task() {
|
|
3675
|
+
return this.#task;
|
|
3676
|
+
}
|
|
3677
|
+
get attempt() {
|
|
3678
|
+
return this.#attempt;
|
|
3679
|
+
}
|
|
3178
3680
|
get aborted() {
|
|
3179
|
-
return this
|
|
3681
|
+
return this.#signal.aborted;
|
|
3180
3682
|
}
|
|
3181
3683
|
get paused() {
|
|
3182
3684
|
if (this.#ancestorTerminal()) return false;
|
|
@@ -3189,7 +3691,7 @@ var TaskController = class {
|
|
|
3189
3691
|
return this.#pulse();
|
|
3190
3692
|
}
|
|
3191
3693
|
async wait() {
|
|
3192
|
-
while (this.paused && !this
|
|
3694
|
+
while (this.paused && !this.#signal.aborted) await this.#race(this.#gates());
|
|
3193
3695
|
}
|
|
3194
3696
|
results() {
|
|
3195
3697
|
return this.#results();
|
|
@@ -3203,11 +3705,11 @@ var TaskController = class {
|
|
|
3203
3705
|
return gates;
|
|
3204
3706
|
}
|
|
3205
3707
|
async #race(gates) {
|
|
3206
|
-
if (this
|
|
3708
|
+
if (this.#signal.aborted || gates.length === 0) return;
|
|
3207
3709
|
const deferred = Promise.withResolvers();
|
|
3208
3710
|
const onAbort = this.#resolve.bind(this, deferred);
|
|
3209
3711
|
const onTerminal = this.#resolve.bind(this, deferred);
|
|
3210
|
-
this
|
|
3712
|
+
this.#signal.addEventListener("abort", onAbort, { once: true });
|
|
3211
3713
|
this.#entity.workflow.emitter.on("skip", onTerminal);
|
|
3212
3714
|
this.#entity.workflow.emitter.on("stop", onTerminal);
|
|
3213
3715
|
this.#entity.phase.emitter.on("skip", onTerminal);
|
|
@@ -3216,7 +3718,7 @@ var TaskController = class {
|
|
|
3216
3718
|
if (this.#ancestorTerminal()) deferred.resolve();
|
|
3217
3719
|
await Promise.race([Promise.all(gates), deferred.promise]);
|
|
3218
3720
|
} finally {
|
|
3219
|
-
this
|
|
3721
|
+
this.#signal.removeEventListener("abort", onAbort);
|
|
3220
3722
|
this.#entity.workflow.emitter.off("skip", onTerminal);
|
|
3221
3723
|
this.#entity.workflow.emitter.off("stop", onTerminal);
|
|
3222
3724
|
this.#entity.phase.emitter.off("skip", onTerminal);
|
|
@@ -3233,24 +3735,34 @@ var TaskController = class {
|
|
|
3233
3735
|
//#endregion
|
|
3234
3736
|
//#region src/core/WorkflowPersistence.ts
|
|
3235
3737
|
/**
|
|
3236
|
-
*
|
|
3738
|
+
* Coordinates advanced run-local snapshot persistence with one writer and one coalesced most
|
|
3739
|
+
* recent obligation, normally composed through `execute({ store })` rather than built directly.
|
|
3237
3740
|
*
|
|
3238
3741
|
* @remarks
|
|
3239
|
-
*
|
|
3240
|
-
*
|
|
3742
|
+
* Exported for hosts that need to coordinate the same required boundaries around their own runner
|
|
3743
|
+
* integration.
|
|
3744
|
+
*
|
|
3745
|
+
* @example
|
|
3746
|
+
* ```ts
|
|
3747
|
+
* import { WorkflowPersistence, createMemoryWorkflowStore, createWorkflow } from '@orkestrel/workflow'
|
|
3748
|
+
*
|
|
3749
|
+
* const workflow = createWorkflow({ id: 'durable', name: 'Durable', phases: [] })
|
|
3750
|
+
* const persistence = new WorkflowPersistence(workflow, createMemoryWorkflowStore())
|
|
3751
|
+
* await persistence.checkpoint('initial')
|
|
3752
|
+
* const durable = await persistence.finalize()
|
|
3753
|
+
* persistence.detach() // idempotent after finalize
|
|
3754
|
+
* ```
|
|
3241
3755
|
*/
|
|
3242
3756
|
var WorkflowPersistence = class {
|
|
3243
3757
|
#workflow;
|
|
3244
3758
|
#store;
|
|
3245
3759
|
#phases = /* @__PURE__ */ new Set();
|
|
3246
3760
|
#tasks = /* @__PURE__ */ new Set();
|
|
3247
|
-
#
|
|
3761
|
+
#onChange;
|
|
3248
3762
|
#onWorkflowAdd;
|
|
3249
3763
|
#onWorkflowRemove;
|
|
3250
|
-
#onPhaseChange;
|
|
3251
3764
|
#onPhaseAdd;
|
|
3252
3765
|
#onPhaseRemove;
|
|
3253
|
-
#onTaskChange;
|
|
3254
3766
|
#writing;
|
|
3255
3767
|
#error;
|
|
3256
3768
|
#fault;
|
|
@@ -3260,32 +3772,29 @@ var WorkflowPersistence = class {
|
|
|
3260
3772
|
constructor(workflow, store) {
|
|
3261
3773
|
this.#workflow = workflow;
|
|
3262
3774
|
this.#store = store;
|
|
3263
|
-
this.#
|
|
3775
|
+
this.#onChange = this.#change.bind(this);
|
|
3264
3776
|
this.#onWorkflowAdd = this.#addPhase.bind(this);
|
|
3265
3777
|
this.#onWorkflowRemove = this.#removePhase.bind(this);
|
|
3266
|
-
this.#onPhaseChange = this.#change.bind(this);
|
|
3267
3778
|
this.#onPhaseAdd = this.#addTask.bind(this);
|
|
3268
3779
|
this.#onPhaseRemove = this.#removeTask.bind(this);
|
|
3269
|
-
this.#onTaskChange = this.#change.bind(this);
|
|
3270
3780
|
this.#attachWorkflow();
|
|
3271
3781
|
}
|
|
3272
3782
|
get fault() {
|
|
3273
3783
|
return this.#fault;
|
|
3274
3784
|
}
|
|
3275
3785
|
/**
|
|
3276
|
-
*
|
|
3786
|
+
* Persists every change through this required boundary.
|
|
3277
3787
|
*
|
|
3278
3788
|
* @param checkpoint - The boundary being made durable
|
|
3279
3789
|
* @param task - The task owning an attempt or settlement
|
|
3280
3790
|
* @param attempt - The persisted attempt number
|
|
3281
|
-
* @returns
|
|
3791
|
+
* @returns True if the most recent state reached the store; false otherwise
|
|
3282
3792
|
*/
|
|
3283
3793
|
async checkpoint(checkpoint, task, attempt) {
|
|
3284
3794
|
const revision = this.#mark();
|
|
3285
3795
|
while (this.#stored < revision) await this.#flush();
|
|
3286
3796
|
if (this.#error === void 0) return true;
|
|
3287
3797
|
if (this.#fault === void 0) this.#fault = Object.freeze({
|
|
3288
|
-
origin: "persistence",
|
|
3289
3798
|
checkpoint,
|
|
3290
3799
|
message: this.#error,
|
|
3291
3800
|
...task === void 0 ? {} : { task: task.id },
|
|
@@ -3294,37 +3803,25 @@ var WorkflowPersistence = class {
|
|
|
3294
3803
|
return false;
|
|
3295
3804
|
}
|
|
3296
3805
|
/**
|
|
3297
|
-
*
|
|
3806
|
+
* Stops observing the live tree and persists its final state.
|
|
3298
3807
|
*
|
|
3299
|
-
* @returns
|
|
3808
|
+
* @returns True if the final snapshot reached the store; false otherwise
|
|
3300
3809
|
*/
|
|
3301
3810
|
async finalize() {
|
|
3302
3811
|
this.detach();
|
|
3303
3812
|
return this.checkpoint("final");
|
|
3304
3813
|
}
|
|
3305
|
-
/**
|
|
3814
|
+
/** Stops observing the live tree. */
|
|
3306
3815
|
detach() {
|
|
3307
3816
|
if (!this.#attached) return;
|
|
3308
3817
|
this.#attached = false;
|
|
3309
|
-
this.#workflow.emitter.off(
|
|
3310
|
-
this.#workflow.emitter.off("complete", this.#onWorkflowChange);
|
|
3311
|
-
this.#workflow.emitter.off("fail", this.#onWorkflowChange);
|
|
3312
|
-
this.#workflow.emitter.off("skip", this.#onWorkflowChange);
|
|
3313
|
-
this.#workflow.emitter.off("stop", this.#onWorkflowChange);
|
|
3314
|
-
this.#workflow.emitter.off("move", this.#onWorkflowChange);
|
|
3315
|
-
this.#workflow.emitter.off("update", this.#onWorkflowChange);
|
|
3818
|
+
for (const event of PERSISTED_NODE_EVENTS) this.#workflow.emitter.off(event, this.#onChange);
|
|
3316
3819
|
this.#workflow.emitter.off("add", this.#onWorkflowAdd);
|
|
3317
3820
|
this.#workflow.emitter.off("remove", this.#onWorkflowRemove);
|
|
3318
3821
|
for (const phase of this.#phases) this.#detachPhase(phase);
|
|
3319
3822
|
}
|
|
3320
3823
|
#attachWorkflow() {
|
|
3321
|
-
this.#workflow.emitter.on(
|
|
3322
|
-
this.#workflow.emitter.on("complete", this.#onWorkflowChange);
|
|
3323
|
-
this.#workflow.emitter.on("fail", this.#onWorkflowChange);
|
|
3324
|
-
this.#workflow.emitter.on("skip", this.#onWorkflowChange);
|
|
3325
|
-
this.#workflow.emitter.on("stop", this.#onWorkflowChange);
|
|
3326
|
-
this.#workflow.emitter.on("move", this.#onWorkflowChange);
|
|
3327
|
-
this.#workflow.emitter.on("update", this.#onWorkflowChange);
|
|
3824
|
+
for (const event of PERSISTED_NODE_EVENTS) this.#workflow.emitter.on(event, this.#onChange);
|
|
3328
3825
|
this.#workflow.emitter.on("add", this.#onWorkflowAdd);
|
|
3329
3826
|
this.#workflow.emitter.on("remove", this.#onWorkflowRemove);
|
|
3330
3827
|
for (const phase of this.#workflow.phases.phases()) this.#attachPhase(phase);
|
|
@@ -3332,26 +3829,14 @@ var WorkflowPersistence = class {
|
|
|
3332
3829
|
#attachPhase(phase) {
|
|
3333
3830
|
if (this.#phases.has(phase)) return;
|
|
3334
3831
|
this.#phases.add(phase);
|
|
3335
|
-
phase.emitter.on(
|
|
3336
|
-
phase.emitter.on("complete", this.#onPhaseChange);
|
|
3337
|
-
phase.emitter.on("fail", this.#onPhaseChange);
|
|
3338
|
-
phase.emitter.on("skip", this.#onPhaseChange);
|
|
3339
|
-
phase.emitter.on("stop", this.#onPhaseChange);
|
|
3340
|
-
phase.emitter.on("move", this.#onPhaseChange);
|
|
3341
|
-
phase.emitter.on("update", this.#onPhaseChange);
|
|
3832
|
+
for (const event of PERSISTED_NODE_EVENTS) phase.emitter.on(event, this.#onChange);
|
|
3342
3833
|
phase.emitter.on("add", this.#onPhaseAdd);
|
|
3343
3834
|
phase.emitter.on("remove", this.#onPhaseRemove);
|
|
3344
3835
|
for (const task of phase.tasks.tasks()) this.#attachTask(task);
|
|
3345
3836
|
}
|
|
3346
3837
|
#detachPhase(phase) {
|
|
3347
3838
|
if (!this.#phases.delete(phase)) return;
|
|
3348
|
-
phase.emitter.off(
|
|
3349
|
-
phase.emitter.off("complete", this.#onPhaseChange);
|
|
3350
|
-
phase.emitter.off("fail", this.#onPhaseChange);
|
|
3351
|
-
phase.emitter.off("skip", this.#onPhaseChange);
|
|
3352
|
-
phase.emitter.off("stop", this.#onPhaseChange);
|
|
3353
|
-
phase.emitter.off("move", this.#onPhaseChange);
|
|
3354
|
-
phase.emitter.off("update", this.#onPhaseChange);
|
|
3839
|
+
for (const event of PERSISTED_NODE_EVENTS) phase.emitter.off(event, this.#onChange);
|
|
3355
3840
|
phase.emitter.off("add", this.#onPhaseAdd);
|
|
3356
3841
|
phase.emitter.off("remove", this.#onPhaseRemove);
|
|
3357
3842
|
for (const task of phase.tasks.tasks()) this.#detachTask(task);
|
|
@@ -3359,23 +3844,11 @@ var WorkflowPersistence = class {
|
|
|
3359
3844
|
#attachTask(task) {
|
|
3360
3845
|
if (this.#tasks.has(task)) return;
|
|
3361
3846
|
this.#tasks.add(task);
|
|
3362
|
-
task.emitter.on(
|
|
3363
|
-
task.emitter.on("complete", this.#onTaskChange);
|
|
3364
|
-
task.emitter.on("fail", this.#onTaskChange);
|
|
3365
|
-
task.emitter.on("skip", this.#onTaskChange);
|
|
3366
|
-
task.emitter.on("stop", this.#onTaskChange);
|
|
3367
|
-
task.emitter.on("report", this.#onTaskChange);
|
|
3368
|
-
task.emitter.on("pulse", this.#onTaskChange);
|
|
3847
|
+
for (const event of PERSISTED_TASK_EVENTS) task.emitter.on(event, this.#onChange);
|
|
3369
3848
|
}
|
|
3370
3849
|
#detachTask(task) {
|
|
3371
3850
|
if (!this.#tasks.delete(task)) return;
|
|
3372
|
-
task.emitter.off(
|
|
3373
|
-
task.emitter.off("complete", this.#onTaskChange);
|
|
3374
|
-
task.emitter.off("fail", this.#onTaskChange);
|
|
3375
|
-
task.emitter.off("skip", this.#onTaskChange);
|
|
3376
|
-
task.emitter.off("stop", this.#onTaskChange);
|
|
3377
|
-
task.emitter.off("report", this.#onTaskChange);
|
|
3378
|
-
task.emitter.off("pulse", this.#onTaskChange);
|
|
3851
|
+
for (const event of PERSISTED_TASK_EVENTS) task.emitter.off(event, this.#onChange);
|
|
3379
3852
|
}
|
|
3380
3853
|
#addPhase(phase) {
|
|
3381
3854
|
this.#attachPhase(phase);
|
|
@@ -3433,82 +3906,83 @@ var WorkflowPersistence = class {
|
|
|
3433
3906
|
//#endregion
|
|
3434
3907
|
//#region src/core/WorkflowRunner.ts
|
|
3435
3908
|
/**
|
|
3436
|
-
*
|
|
3437
|
-
* substrate — phases sequential, tasks concurrent — dispatching each task through its
|
|
3909
|
+
* Implements the thin orchestrator that executes a live W-b workflow tree by composing the shipped
|
|
3910
|
+
* substrate — phases sequential, tasks concurrent — dispatching each task through its own
|
|
3438
3911
|
* resolved handler under the `bail` policy.
|
|
3439
3912
|
*
|
|
3440
3913
|
* @remarks
|
|
3441
3914
|
* - **Composes, never re-implements.** Per-phase bounded concurrency is one
|
|
3442
|
-
* {@link createRunner} per phase (the substrate {@link RunnerInterface}
|
|
3915
|
+
* {@link createRunner} per phase (the substrate {@link import('./types.js').RunnerInterface}
|
|
3916
|
+
* over the workers
|
|
3443
3917
|
* `Queue`); `bail` maps onto that Runner's fail-fast vs settle-all; the run-level abort /
|
|
3444
3918
|
* timeout / budget / entity `signal` fold through the `@orkestrel/abort` signal contract,
|
|
3445
3919
|
* {@link createTimeout}, and `AbortSignal.any` (exactly as the agent runtime folds its bounds);
|
|
3446
3920
|
* pacing is the shipped
|
|
3447
|
-
* {@link SchedulerInterface}. The runner writes
|
|
3921
|
+
* {@link SchedulerInterface}. The runner writes zero concurrency / retry / abort logic of
|
|
3448
3922
|
* its own — it only sequences phases, dispatches a task's own handler, and drives the live
|
|
3449
3923
|
* entity. The workflow layer owns per-task deadlines because timeout settlement must
|
|
3450
3924
|
* update the live leaf under the phase's `bail` policy before the substrate unit settles.
|
|
3451
3925
|
* - **Pure engine — no integration registry.** The runner carries no behavior or provider
|
|
3452
3926
|
* registry: each live {@link TaskInterface} already
|
|
3453
3927
|
* resolved its own {@link import('./types.js').WorkflowFunction} into
|
|
3454
|
-
* {@link import('./types.js').TaskInterface.handler}
|
|
3928
|
+
* {@link import('./types.js').TaskInterface.handler} once at construction (build, restore,
|
|
3455
3929
|
* or a live mint all resolve it identically, from {@link WorkflowOptions.functions}), so
|
|
3456
|
-
* dispatch is
|
|
3930
|
+
* dispatch is "invoke the task's own handler". Provider, protocol, and tool
|
|
3457
3931
|
* integrations remain application-owned {@link import('./types.js').WorkflowFunction}s
|
|
3458
3932
|
* composed into {@link WorkflowOptions.functions}. This module imports none of them.
|
|
3459
|
-
* - **Two `execute` forms, one engine.** `execute(definition, options)`
|
|
3460
|
-
* from a {@link WorkflowDefinition} (single source of truth for the `
|
|
3461
|
-
* metadata); `execute(workflow, options)`
|
|
3462
|
-
* {@link WorkflowInterface} instead — the entity-native control surface
|
|
3463
|
-
* `pause` / `resume` / `add` / `stop` / `destroy` live on the entity itself). Both forms
|
|
3464
|
-
* converge on the
|
|
3465
|
-
* exists — `#runTask` reads each task's
|
|
3466
|
-
* / `retries` / `timeout`, and `#runPhase` reads each phase's
|
|
3933
|
+
* - **Two `execute` forms, one engine.** `execute(definition, options)` builds the live tree
|
|
3934
|
+
* from a {@link WorkflowDefinition} (single source of truth for the `behavior` / `concurrency`
|
|
3935
|
+
* metadata); `execute(workflow, options)` drives a caller-owned, already-built
|
|
3936
|
+
* {@link WorkflowInterface} instead — the entity-native control surface
|
|
3937
|
+
* (`pause` / `resume` / `add` / `stop` / `destroy` live on the entity itself). Both forms
|
|
3938
|
+
* converge on the same `#execute` engine: neither reads a `WorkflowDefinition` after the tree
|
|
3939
|
+
* exists — `#runTask` reads each task's own {@link import('./types.js').TaskInterface.handler}
|
|
3940
|
+
* / `retries` / `timeout`, and `#runPhase` reads each phase's own
|
|
3467
3941
|
* {@link PhaseInterface.concurrency} / `bail`, so a live `add`-minted phase or task (V5)
|
|
3468
|
-
* runs under
|
|
3469
|
-
* - **Phases sequential, tasks concurrent —
|
|
3470
|
-
* order,
|
|
3942
|
+
* runs under exactly the same rules as one built from the original definition.
|
|
3943
|
+
* - **Phases sequential, tasks concurrent — live continuity.** `#execute` drives the phases in
|
|
3944
|
+
* order, re-reading `workflow.phases.phases()` every iteration (a cursor over the live
|
|
3471
3945
|
* manager, not a one-time snapshot) so a caller's `workflow.add(phaseDefinition)` mid-run is
|
|
3472
|
-
* picked up. Within a phase, `#runPhase` subscribes to that phase's `add` event
|
|
3473
|
-
* capturing its task list, then `spawn`s any task added mid-phase onto the
|
|
3946
|
+
* picked up. Within a phase, `#runPhase` subscribes to that phase's `add` event before
|
|
3947
|
+
* capturing its task list, then `spawn`s any task added mid-phase onto the same substrate
|
|
3474
3948
|
* Runner (so it is actually dispatched, under the same `concurrency`); a task added too late
|
|
3475
3949
|
* for `spawn` to accept (the runner already drained) is swept `skip`ped afterward so the
|
|
3476
3950
|
* phase always reaches a coherent terminal state.
|
|
3477
3951
|
* - **Dispatch by handler.** `#runTask` invokes the live task's own
|
|
3478
|
-
* {@link import('./types.js').TaskInterface.handler} directly. An omitted `
|
|
3952
|
+
* {@link import('./types.js').TaskInterface.handler} directly. An omitted `behavior` deliberately
|
|
3479
3953
|
* auto-completes with JSON `null`; a present unresolved name is rejected by the synchronous
|
|
3480
3954
|
* execution claim and never false-completes.
|
|
3481
3955
|
* - **`bail` → substrate.** Under `bail: true` (halt) a genuine task failure `fail`s the leaf
|
|
3482
|
-
*
|
|
3956
|
+
* then re-throws, so the substrate Runner fail-fasts — it aborts the in-flight siblings
|
|
3483
3957
|
* (their `controller.signal` fires; a mid-flight sibling `skip`s) and rejects the phase run;
|
|
3484
3958
|
* `#execute` then `skip`s the remaining tasks / phases (the workflow derives `failed`).
|
|
3485
|
-
* Under `bail: false` (graceful) a failure `fail`s the leaf and
|
|
3959
|
+
* Under `bail: false` (graceful) a failure `fail`s the leaf and resolves (never throws), so
|
|
3486
3960
|
* the Runner settles every unit (allSettled) and the run finishes (the workflow derives
|
|
3487
3961
|
* `completed`, the failure recorded in the result tree).
|
|
3488
3962
|
* - **Pause / stop / destroy gates.** Workflow, phase, and task gates are checked before
|
|
3489
3963
|
* dispatch, and a running handler can checkpoint their folded state through
|
|
3490
3964
|
* {@link import('./types.js').TaskControllerInterface.wait}. Because the substrate acquires
|
|
3491
3965
|
* concurrency before this handler gate, a paused task occupies one phase slot until resume;
|
|
3492
|
-
* already-running siblings continue and its per-attempt timeout keeps counting. A
|
|
3966
|
+
* already-running siblings continue and its per-attempt timeout keeps counting. A graceful
|
|
3493
3967
|
* `workflow.stop()` (no signal involved) is caught at
|
|
3494
3968
|
* those same gates: not-yet-started work is `skip`ped, in-flight work finishes naturally. A
|
|
3495
|
-
*
|
|
3969
|
+
* hard `workflow.destroy()` aborts {@link WorkflowInterface.signal}, which `#fold` has folded
|
|
3496
3970
|
* into the run's composed signal — so it cancels the active phase Runner (and every
|
|
3497
|
-
* in-flight task) exactly like an external abort / timeout / budget fire.
|
|
3498
|
-
* `wait()` gate is
|
|
3499
|
-
*
|
|
3971
|
+
* in-flight task) exactly like an external abort / timeout / budget fire. Every park on a
|
|
3972
|
+
* `wait()` gate is raced against that same run signal (`#raceWait`, S2) — so a cancel firing
|
|
3973
|
+
* while parked unparks the engine promptly instead of hanging until `resume`; the existing
|
|
3500
3974
|
* halt / abort re-checks after the gate then decide the outcome.
|
|
3501
3975
|
* - **Abort / Timeout / Budget / entity-signal fold.** `#execute` folds the live workflow's
|
|
3502
3976
|
* own {@link WorkflowInterface.signal}, the run's external `signal`, a
|
|
3503
3977
|
* {@link TimeoutInterface}, and the `@orkestrel/budget` package's `BudgetInterface`'s
|
|
3504
3978
|
* `signal` into one `runSignal` (`AbortSignal.any`); a fire aborts the active phase's Runner
|
|
3505
|
-
* (cancelling every in-flight task) and
|
|
3979
|
+
* (cancelling every in-flight task) and halts the run — the remaining tasks / phases `skip`
|
|
3506
3980
|
* and the workflow is force-`stop`ped (settles `stopped`). Each task's
|
|
3507
3981
|
* {@link TaskController} signal `AbortSignal.any`-combines the substrate per-unit signal with
|
|
3508
3982
|
* `runSignal`, so a handler observes either cause directly.
|
|
3509
|
-
* - **Re-entrant-safe.** No shared per-run mutable field:
|
|
3510
|
-
*
|
|
3511
|
-
* state.
|
|
3983
|
+
* - **Re-entrant-safe.** No shared per-run mutable field: each `#execute` mints its own
|
|
3984
|
+
* {@link import('./RunHolder.js').RunHolder}, so a nested application-level `execute` cannot
|
|
3985
|
+
* clobber the outer run's state.
|
|
3512
3986
|
*/
|
|
3513
3987
|
var WorkflowRunner = class WorkflowRunner {
|
|
3514
3988
|
static #executions = /* @__PURE__ */ new WeakSet();
|
|
@@ -3517,7 +3991,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3517
3991
|
this.#scheduler = scheduler;
|
|
3518
3992
|
}
|
|
3519
3993
|
execute(target, options) {
|
|
3520
|
-
if (
|
|
3994
|
+
if (isWorkflowInterface(target)) {
|
|
3521
3995
|
const signal = options?.signal;
|
|
3522
3996
|
const timeout = options?.timeout;
|
|
3523
3997
|
const budget = options?.budget;
|
|
@@ -3530,7 +4004,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3530
4004
|
const timeout = options?.timeout;
|
|
3531
4005
|
const budget = options?.budget;
|
|
3532
4006
|
const store = options?.store;
|
|
3533
|
-
const workflow =
|
|
4007
|
+
const workflow = createWorkflowTree(target, captured);
|
|
3534
4008
|
this.#acquire(workflow);
|
|
3535
4009
|
return this.#execute(workflow, signal, timeout, budget, store);
|
|
3536
4010
|
}
|
|
@@ -3544,7 +4018,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3544
4018
|
WorkflowRunner.#executions.add(workflow);
|
|
3545
4019
|
}
|
|
3546
4020
|
async #execute(workflow, signal, ms, budget, store) {
|
|
3547
|
-
const holder =
|
|
4021
|
+
const holder = new RunHolder();
|
|
3548
4022
|
let timeout;
|
|
3549
4023
|
let persistence;
|
|
3550
4024
|
let runSignal;
|
|
@@ -3559,7 +4033,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3559
4033
|
if (runSignal.aborted) onCancel();
|
|
3560
4034
|
else runSignal.addEventListener("abort", onCancel, { once: true });
|
|
3561
4035
|
if (persistence !== void 0 && !await persistence.checkpoint("initial")) {
|
|
3562
|
-
if (
|
|
4036
|
+
if (isStoppable(workflow)) workflow.stop();
|
|
3563
4037
|
this.#skipFrom(workflow.phases.phases(), 0);
|
|
3564
4038
|
}
|
|
3565
4039
|
let index = 0;
|
|
@@ -3571,12 +4045,12 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3571
4045
|
index += 1;
|
|
3572
4046
|
continue;
|
|
3573
4047
|
}
|
|
3574
|
-
if (
|
|
4048
|
+
if (runSignal.aborted || isHalted(workflow)) {
|
|
3575
4049
|
this.#haltFrom(phases, index, workflow, runSignal);
|
|
3576
4050
|
break;
|
|
3577
4051
|
}
|
|
3578
4052
|
if (workflow.paused) await this.#raceWait(workflow.wait(), runSignal, void 0, workflow);
|
|
3579
|
-
if (
|
|
4053
|
+
if (runSignal.aborted || isHalted(workflow)) {
|
|
3580
4054
|
this.#haltFrom(workflow.phases.phases(), index, workflow, runSignal);
|
|
3581
4055
|
break;
|
|
3582
4056
|
}
|
|
@@ -3591,10 +4065,10 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3591
4065
|
}
|
|
3592
4066
|
index += 1;
|
|
3593
4067
|
const remaining = workflow.phases.phases();
|
|
3594
|
-
if (index < remaining.length && !
|
|
4068
|
+
if (index < remaining.length && !runSignal.aborted) await this.#pace(runSignal);
|
|
3595
4069
|
}
|
|
3596
|
-
if (
|
|
3597
|
-
else if (
|
|
4070
|
+
if (runSignal.aborted) this.#haltFrom(workflow.phases.phases(), 0, workflow, runSignal);
|
|
4071
|
+
else if (isCompletable(workflow)) workflow.complete();
|
|
3598
4072
|
const durable = await persistence?.finalize();
|
|
3599
4073
|
return {
|
|
3600
4074
|
workflow,
|
|
@@ -3604,7 +4078,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3604
4078
|
...persistence?.fault === void 0 ? {} : { fault: persistence.fault }
|
|
3605
4079
|
};
|
|
3606
4080
|
} catch (error) {
|
|
3607
|
-
if (
|
|
4081
|
+
if (isStoppable(workflow)) workflow.stop();
|
|
3608
4082
|
this.#skipFrom(workflow.phases.phases(), 0);
|
|
3609
4083
|
await persistence?.finalize();
|
|
3610
4084
|
throw error;
|
|
@@ -3639,22 +4113,22 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3639
4113
|
entries: this.#entry.bind(this),
|
|
3640
4114
|
handler: this.#runUnit.bind(this, workflow, runSignal, bail, attempts, owners, persistence)
|
|
3641
4115
|
});
|
|
3642
|
-
holder.
|
|
4116
|
+
holder.hold(created);
|
|
3643
4117
|
try {
|
|
3644
4118
|
await created.execute(tasks);
|
|
3645
4119
|
return false;
|
|
3646
4120
|
} catch {
|
|
3647
|
-
return !
|
|
4121
|
+
return !runSignal.aborted;
|
|
3648
4122
|
} finally {
|
|
3649
4123
|
try {
|
|
3650
4124
|
await created.destroy();
|
|
3651
4125
|
} finally {
|
|
3652
|
-
holder.
|
|
4126
|
+
holder.hold();
|
|
3653
4127
|
}
|
|
3654
4128
|
}
|
|
3655
4129
|
} finally {
|
|
3656
4130
|
phase.emitter.off("add", onAdd);
|
|
3657
|
-
if (
|
|
4131
|
+
if (runSignal.aborted && isStoppable(workflow)) workflow.stop();
|
|
3658
4132
|
for (const task of phase.tasks.tasks()) this.#skip(task);
|
|
3659
4133
|
}
|
|
3660
4134
|
}
|
|
@@ -3678,7 +4152,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3678
4152
|
attempts.set(task.id, attempt);
|
|
3679
4153
|
const last = attempt > Math.max(0, task.retries ?? 0);
|
|
3680
4154
|
if (task.status !== "pending" && task.status !== "running") return;
|
|
3681
|
-
if (
|
|
4155
|
+
if (isSkipping(task, controller, runSignal) || isHalted(workflow, task.phase)) {
|
|
3682
4156
|
this.#settleCancelled(task, workflow, runSignal);
|
|
3683
4157
|
return;
|
|
3684
4158
|
}
|
|
@@ -3691,15 +4165,15 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3691
4165
|
owners.set(task.id, attempt);
|
|
3692
4166
|
deadline?.start();
|
|
3693
4167
|
const durable = persistence === void 0 ? true : await persistence.checkpoint("attempt", task, attempt);
|
|
3694
|
-
if (!
|
|
4168
|
+
if (!ownsAttempt(owners, task, attempt)) return;
|
|
3695
4169
|
if (!durable) {
|
|
3696
|
-
if (
|
|
4170
|
+
if (isStoppable(workflow)) workflow.stop();
|
|
3697
4171
|
return;
|
|
3698
4172
|
}
|
|
3699
|
-
if (task.
|
|
3700
|
-
const error = new WorkflowError("TRANSITION", `task '${task.id}' has an unresolved
|
|
4173
|
+
if (task.behavior !== void 0 && task.handler === void 0) {
|
|
4174
|
+
const error = new WorkflowError("TRANSITION", `task '${task.id}' has an unresolved behavior '${task.behavior}'`, {
|
|
3701
4175
|
task: task.id,
|
|
3702
|
-
|
|
4176
|
+
behavior: task.behavior
|
|
3703
4177
|
});
|
|
3704
4178
|
task.fail({
|
|
3705
4179
|
origin: "handler",
|
|
@@ -3711,21 +4185,21 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3711
4185
|
if (await this.#gate(workflow.paused ? workflow.wait() : void 0, task, workflow, controller, runSignal, signal, attempts, owners, attempt, last, bail)) return;
|
|
3712
4186
|
if (await this.#gate(task.phase.paused ? task.phase.wait() : void 0, task, workflow, controller, runSignal, signal, attempts, owners, attempt, last, bail)) return;
|
|
3713
4187
|
if (await this.#gate(task.paused ? task.wait() : void 0, task, workflow, controller, runSignal, signal, attempts, owners, attempt, last, bail)) return;
|
|
3714
|
-
if (
|
|
4188
|
+
if (isSkipping(task, controller, runSignal) || isHalted(workflow, task.phase)) {
|
|
3715
4189
|
this.#settleCancelled(task, workflow, runSignal);
|
|
3716
4190
|
return;
|
|
3717
4191
|
}
|
|
3718
4192
|
if (task.status !== "running") return;
|
|
3719
|
-
const handle = new TaskController(signal, task.snapshot().metadata, task, attempt, () => workflow.results(), (input) =>
|
|
4193
|
+
const handle = new TaskController(signal, task.snapshot().metadata, task, attempt, () => workflow.results(), (input) => ownsAttempt(owners, task, attempt) && !signal.aborted ? task.report(input) : failure(new WorkflowError("TRANSITION", `task '${task.id}' attempt '${attempt}' no longer owns activity`, {
|
|
3720
4194
|
task: task.id,
|
|
3721
4195
|
attempt
|
|
3722
|
-
})), () =>
|
|
4196
|
+
})), () => ownsAttempt(owners, task, attempt) && !signal.aborted && task.pulse());
|
|
3723
4197
|
let outcome;
|
|
3724
4198
|
try {
|
|
3725
|
-
outcome = task.handler === void 0 ? [true, null] : await this.#raceHandler(Promise.resolve(task.handler(handle)), signal,
|
|
4199
|
+
outcome = task.handler === void 0 ? [true, null] : await this.#raceHandler(Promise.resolve(task.handler(handle)), signal, () => isSkipping(task, controller, runSignal));
|
|
3726
4200
|
} catch (error) {
|
|
3727
|
-
if (!
|
|
3728
|
-
if (task.status !== "running" ||
|
|
4201
|
+
if (!ownsAttempt(owners, task, attempt)) return;
|
|
4202
|
+
if (task.status !== "running" || isSkipping(task, controller, runSignal)) {
|
|
3729
4203
|
this.#settleCancelled(task, workflow, runSignal);
|
|
3730
4204
|
return;
|
|
3731
4205
|
}
|
|
@@ -3736,13 +4210,13 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3736
4210
|
this.#failed(owners, task, attempt, error, last, bail);
|
|
3737
4211
|
return;
|
|
3738
4212
|
}
|
|
3739
|
-
if (!
|
|
4213
|
+
if (!ownsAttempt(owners, task, attempt)) return;
|
|
3740
4214
|
if (!outcome[0]) {
|
|
3741
4215
|
this.#settleAttempt(task, workflow, controller, runSignal, signal, attempts, owners, attempt, last, bail, outcome[2]);
|
|
3742
4216
|
return;
|
|
3743
4217
|
}
|
|
3744
4218
|
if (task.status !== "running") return;
|
|
3745
|
-
if (
|
|
4219
|
+
if (isSkipping(task, controller, runSignal)) {
|
|
3746
4220
|
this.#settleCancelled(task, workflow, runSignal);
|
|
3747
4221
|
return;
|
|
3748
4222
|
}
|
|
@@ -3750,32 +4224,32 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3750
4224
|
this.#timedOut(owners, task, attempt, last, bail);
|
|
3751
4225
|
return;
|
|
3752
4226
|
}
|
|
3753
|
-
if (!
|
|
4227
|
+
if (!ownsAttempt(owners, task, attempt)) return;
|
|
3754
4228
|
try {
|
|
3755
4229
|
task.complete(outcome[1]);
|
|
3756
4230
|
} catch (error) {
|
|
3757
|
-
if (!
|
|
4231
|
+
if (!ownsAttempt(owners, task, attempt)) return;
|
|
3758
4232
|
if (task.status !== "running") throw error;
|
|
3759
4233
|
this.#failed(owners, task, attempt, error, last, bail);
|
|
3760
4234
|
}
|
|
3761
4235
|
} finally {
|
|
3762
4236
|
deadline?.clear();
|
|
3763
|
-
if (persistence !== void 0 &&
|
|
4237
|
+
if (persistence !== void 0 && ownsAttempt(owners, task, attempt) && isTerminalStatus(task.status) && !await persistence.checkpoint("settlement", task, attempt) && isStoppable(workflow)) workflow.stop();
|
|
3764
4238
|
this.#revoke(owners, task.id, attempt);
|
|
3765
4239
|
}
|
|
3766
4240
|
}
|
|
3767
4241
|
async #gate(wait, task, workflow, controller, runSignal, signal, attempts, owners, attempt, last, bail) {
|
|
3768
|
-
const genuine = wait === void 0 ? void 0 : await this.#raceWait(wait, signal,
|
|
4242
|
+
const genuine = wait === void 0 ? void 0 : await this.#raceWait(wait, signal, () => isSkipping(task, controller, runSignal), workflow, task.phase);
|
|
3769
4243
|
return this.#settleAttempt(task, workflow, controller, runSignal, signal, attempts, owners, attempt, last, bail, genuine);
|
|
3770
4244
|
}
|
|
3771
4245
|
#settleAttempt(task, workflow, controller, runSignal, signal, attempts, owners, attempt, last, bail, genuine) {
|
|
3772
|
-
if (attempts.get(task.id) !== attempt || !
|
|
4246
|
+
if (attempts.get(task.id) !== attempt || !ownsAttempt(owners, task, attempt)) return true;
|
|
3773
4247
|
if (signal.aborted) {
|
|
3774
|
-
if (genuine ??
|
|
4248
|
+
if (genuine ?? isSkipping(task, controller, runSignal)) this.#settleCancelled(task, workflow, runSignal);
|
|
3775
4249
|
else this.#timedOut(owners, task, attempt, last, bail);
|
|
3776
4250
|
return true;
|
|
3777
4251
|
}
|
|
3778
|
-
if (
|
|
4252
|
+
if (isSkipping(task, controller, runSignal) || isHalted(workflow, task.phase)) {
|
|
3779
4253
|
this.#settleCancelled(task, workflow, runSignal);
|
|
3780
4254
|
return true;
|
|
3781
4255
|
}
|
|
@@ -3804,7 +4278,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3804
4278
|
]);
|
|
3805
4279
|
}
|
|
3806
4280
|
#timedOut(owners, task, attempt, last, bail) {
|
|
3807
|
-
if (!
|
|
4281
|
+
if (!ownsAttempt(owners, task, attempt)) return;
|
|
3808
4282
|
const error = /* @__PURE__ */ new Error(`task '${task.id}' timed out`);
|
|
3809
4283
|
if (last) task.fail({
|
|
3810
4284
|
origin: "timeout",
|
|
@@ -3813,7 +4287,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3813
4287
|
if (!last || bail) throw error;
|
|
3814
4288
|
}
|
|
3815
4289
|
#failed(owners, task, attempt, error, last, bail) {
|
|
3816
|
-
if (!
|
|
4290
|
+
if (!ownsAttempt(owners, task, attempt)) return;
|
|
3817
4291
|
if (!last) throw error;
|
|
3818
4292
|
task.fail({
|
|
3819
4293
|
origin: "handler",
|
|
@@ -3821,9 +4295,6 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3821
4295
|
});
|
|
3822
4296
|
if (bail) throw error;
|
|
3823
4297
|
}
|
|
3824
|
-
#owns(owners, task, attempt) {
|
|
3825
|
-
return owners.get(task.id) === attempt && task.attempts === attempt;
|
|
3826
|
-
}
|
|
3827
4298
|
#revoke(owners, id, attempt) {
|
|
3828
4299
|
if (owners.get(id) === attempt) owners.delete(id);
|
|
3829
4300
|
}
|
|
@@ -3838,7 +4309,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3838
4309
|
phase?.emitter.on("skip", onTerminal);
|
|
3839
4310
|
phase?.emitter.on("stop", onTerminal);
|
|
3840
4311
|
try {
|
|
3841
|
-
if (workflow !== void 0 &&
|
|
4312
|
+
if (workflow !== void 0 && isHalted(workflow, phase)) deferred.resolve(void 0);
|
|
3842
4313
|
const outcome = await Promise.race([wait, deferred.promise]);
|
|
3843
4314
|
return typeof outcome === "boolean" ? outcome : void 0;
|
|
3844
4315
|
} finally {
|
|
@@ -3869,7 +4340,7 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3869
4340
|
return signals.length === 1 ? workflow.signal : AbortSignal.any(signals);
|
|
3870
4341
|
}
|
|
3871
4342
|
#haltFrom(phases, index, workflow, runSignal) {
|
|
3872
|
-
if (
|
|
4343
|
+
if (runSignal.aborted && isStoppable(workflow)) workflow.stop();
|
|
3873
4344
|
this.#skipFrom(phases, index);
|
|
3874
4345
|
}
|
|
3875
4346
|
#skipFrom(phases, index) {
|
|
@@ -3880,37 +4351,17 @@ var WorkflowRunner = class WorkflowRunner {
|
|
|
3880
4351
|
}
|
|
3881
4352
|
}
|
|
3882
4353
|
#settleCancelled(task, workflow, runSignal) {
|
|
3883
|
-
if (
|
|
4354
|
+
if (runSignal.aborted && isStoppable(workflow)) workflow.stop();
|
|
3884
4355
|
this.#skip(task);
|
|
3885
4356
|
}
|
|
3886
4357
|
#skip(task) {
|
|
3887
4358
|
if (task.status === "pending" || task.status === "running") task.skip();
|
|
3888
4359
|
}
|
|
3889
|
-
#skipping(task, controller, runSignal) {
|
|
3890
|
-
return task.signal.aborted || controller.aborted || runSignal.aborted;
|
|
3891
|
-
}
|
|
3892
|
-
#cancelled(runSignal) {
|
|
3893
|
-
return runSignal.aborted;
|
|
3894
|
-
}
|
|
3895
|
-
#halted(workflow, phase) {
|
|
3896
|
-
const status = workflow.status;
|
|
3897
|
-
return status === "failed" || status === "skipped" || status === "stopped" || phase?.status === "skipped" || phase?.status === "stopped";
|
|
3898
|
-
}
|
|
3899
|
-
#stoppable(workflow) {
|
|
3900
|
-
const status = workflow.status;
|
|
3901
|
-
return status !== "failed" && status !== "stopped";
|
|
3902
|
-
}
|
|
3903
|
-
#completable(workflow) {
|
|
3904
|
-
return workflow.status === "pending";
|
|
3905
|
-
}
|
|
3906
|
-
#isWorkflow(target) {
|
|
3907
|
-
return "destroyed" in target && "snapshot" in target && typeof target.snapshot === "function";
|
|
3908
|
-
}
|
|
3909
4360
|
};
|
|
3910
4361
|
//#endregion
|
|
3911
4362
|
//#region src/core/factories.ts
|
|
3912
4363
|
/**
|
|
3913
|
-
*
|
|
4364
|
+
* Compiles the workflow definition contract — the JSON Schema, guard, parser, and
|
|
3914
4365
|
* seeded generator for a {@link WorkflowDefinition}, all derived from one shape and
|
|
3915
4366
|
* kept in lockstep.
|
|
3916
4367
|
*
|
|
@@ -3940,23 +4391,23 @@ function createWorkflowContract() {
|
|
|
3940
4391
|
return createContract(workflowShape);
|
|
3941
4392
|
}
|
|
3942
4393
|
/**
|
|
3943
|
-
*
|
|
4394
|
+
* Builds the live W-b entity tree from a {@link WorkflowDefinition} — the whole
|
|
3944
4395
|
* {@link WorkflowInterface} → {@link import('./types.js').PhaseInterface} →
|
|
3945
4396
|
* {@link import('./types.js').TaskInterface} tree, each level wired with its lineage
|
|
3946
|
-
* context, its emitter, and the cascade
|
|
4397
|
+
* context, its emitter, and the cascade, and every node born `pending`.
|
|
3947
4398
|
*
|
|
3948
4399
|
* @remarks
|
|
3949
|
-
* The definition is the
|
|
4400
|
+
* The definition is the declarative blueprint; this seeds an initial all-`pending`
|
|
3950
4401
|
* {@link WorkflowSnapshot} from it ({@link definitionToSnapshot}) and constructs the live
|
|
3951
4402
|
* tree over that one path. The `bail` failure policy resolves to `options.bail`, else the
|
|
3952
4403
|
* definition's `bail`, else the graceful {@link import('./constants.js').DEFAULT_BAIL}; it
|
|
3953
4404
|
* feeds {@link import('./helpers.js').deriveWorkflowStatus}. Per-phase / per-task initial
|
|
3954
4405
|
* listeners + metadata travel through `options.phases[id].on` /
|
|
3955
|
-
* `options.phases[id].tasks[id]` (the
|
|
3956
|
-
* state machine
|
|
4406
|
+
* `options.phases[id].tasks[id]` (the nested-by-id bag). The W-b tree is the
|
|
4407
|
+
* state machine only — it does not execute tasks (W-c drives the transitions).
|
|
3957
4408
|
*
|
|
3958
|
-
* `options.functions` is the {@link import('./types.js').
|
|
3959
|
-
* task's `
|
|
4409
|
+
* `options.functions` is the {@link import('./types.js').WorkflowRegistry} registry each live
|
|
4410
|
+
* task's `behavior` name resolves against once at construction into its runtime
|
|
3960
4411
|
* {@link import('./types.js').TaskInterface.handler}. An omitted name is the deliberate no-op;
|
|
3961
4412
|
* an unresolved present name remains inspectable but is rejected if execution is attempted.
|
|
3962
4413
|
*
|
|
@@ -3974,26 +4425,62 @@ function createWorkflowContract() {
|
|
|
3974
4425
|
* ```
|
|
3975
4426
|
*/
|
|
3976
4427
|
function createWorkflow(definition, options) {
|
|
3977
|
-
|
|
3978
|
-
|
|
4428
|
+
return createWorkflowTree(definition, captureWorkflowOptions(options));
|
|
4429
|
+
}
|
|
4430
|
+
/**
|
|
4431
|
+
* Builds the live entity tree one definition and one owned options bag describe — the shared
|
|
4432
|
+
* construction path behind every definition-driven mint.
|
|
4433
|
+
*
|
|
4434
|
+
* @remarks
|
|
4435
|
+
* Seeds an initial all-`pending` {@link WorkflowSnapshot} from the definition and constructs the
|
|
4436
|
+
* live {@link WorkflowInterface} over it. `bail` is the caller's own override, forwarded to
|
|
4437
|
+
* {@link definitionToSnapshot} so it reaches both tiers: the workflow snapshot and the inheritance
|
|
4438
|
+
* default of every phase that declares no `bail` of its own, while a phase declaring one still
|
|
4439
|
+
* wins. Omitted, the definition's own `bail` governs, defaulting to the graceful
|
|
4440
|
+
* {@link import('./constants.js').DEFAULT_BAIL}.
|
|
4441
|
+
*
|
|
4442
|
+
* `captured` is forwarded to the entity unchanged — its own `bail` is deliberately not replaced
|
|
4443
|
+
* with the resolved policy, because the snapshot already carries the resolved value at both tiers
|
|
4444
|
+
* and an injected one would make `Workflow` read it as an explicit uniform override and clobber
|
|
4445
|
+
* the per-phase overrides. Each task's `behavior` / `retries` / `timeout` travel onto the snapshot
|
|
4446
|
+
* too, so `captured.functions` resolves every handler identically whether the tree is built fresh
|
|
4447
|
+
* or restored. Pass a bag {@link captureWorkflowOptions} already owns: this constructs over it
|
|
4448
|
+
* without re-capturing.
|
|
4449
|
+
*
|
|
4450
|
+
* @param definition - The workflow definition to bring to life
|
|
4451
|
+
* @param captured - The already-owned {@link WorkflowOptions} bag the entity is constructed with,
|
|
4452
|
+
* whose `bail` is the caller's failure-policy override, or `undefined` to take the definition's
|
|
4453
|
+
* @returns The live {@link WorkflowInterface} root
|
|
4454
|
+
*
|
|
4455
|
+
* @example
|
|
4456
|
+
* ```ts
|
|
4457
|
+
* import { captureWorkflowOptions, createWorkflowTree } from '@orkestrel/workflow'
|
|
4458
|
+
*
|
|
4459
|
+
* const captured = captureWorkflowOptions({ bail: true })
|
|
4460
|
+
* const workflow = createWorkflowTree(definition, captured)
|
|
4461
|
+
* workflow.bail // true — the override reached the workflow and every inheriting phase
|
|
4462
|
+
* ```
|
|
4463
|
+
*/
|
|
4464
|
+
function createWorkflowTree(definition, captured) {
|
|
4465
|
+
return new Workflow(definitionToSnapshot(definition, captured.bail), captured);
|
|
3979
4466
|
}
|
|
3980
4467
|
/**
|
|
3981
|
-
*
|
|
4468
|
+
* Builds an equivalent live W-b entity tree from a {@link WorkflowSnapshot} — the
|
|
3982
4469
|
* inverse of {@link WorkflowInterface.snapshot}, restoring structure + each node's status
|
|
3983
4470
|
* + recorded results + positional order + the persisted `#override`.
|
|
3984
4471
|
*
|
|
3985
4472
|
* @remarks
|
|
3986
4473
|
* Round-trip fidelity is paramount: a `snapshot()` → `createRestoredWorkflow()` reproduces the
|
|
3987
|
-
* same status at every node (each `#override` restored
|
|
4474
|
+
* same status at every node (each `#override` restored directly from the snapshot's own
|
|
3988
4475
|
* `override` field, not guessed from a status divergence), the same recorded
|
|
3989
4476
|
* {@link import('./types.js').TaskResult}s, and the same positional order (an interior
|
|
3990
|
-
* `skip` / `remove` survives). The snapshot is
|
|
3991
|
-
* policy it ran under, so the restore re-derives status
|
|
4477
|
+
* `skip` / `remove` survives). The snapshot is self-contained — it persists the `bail`
|
|
4478
|
+
* policy it ran under, so the restore re-derives status identically without a silent
|
|
3992
4479
|
* default; the snapshot's `bail` is the source of truth, while an explicit `options.bail`
|
|
3993
4480
|
* still wins when supplied (to deliberately re-run under a different policy). A structurally
|
|
3994
4481
|
* invalid snapshot (a status — or override — outside the lifecycle vocabulary, or a
|
|
3995
4482
|
* non-boolean `bail`) throws a `RESTORE` {@link WorkflowError}.
|
|
3996
|
-
* Runtime handlers are optional: without a matching `functions` entry, a persisted `
|
|
4483
|
+
* Runtime handlers are optional: without a matching `functions` entry, a persisted `behavior`
|
|
3997
4484
|
* remains visible with an undefined `handler` so the exact state is inspectable. The runner
|
|
3998
4485
|
* rejects that unresolved tree if execution is attempted.
|
|
3999
4486
|
*
|
|
@@ -4014,10 +4501,11 @@ function createRestoredWorkflow(snapshot, options) {
|
|
|
4014
4501
|
return new Workflow(cloneWorkflowSnapshot(snapshot), captured);
|
|
4015
4502
|
}
|
|
4016
4503
|
/**
|
|
4017
|
-
*
|
|
4504
|
+
* Builds an interrupted workflow back to life at its remaining retry budget, normalizing a
|
|
4505
|
+
* leaf whose attempts are exhausted into a recovery failure.
|
|
4018
4506
|
*
|
|
4019
4507
|
* @remarks
|
|
4020
|
-
* Each phase captures every unique initial `
|
|
4508
|
+
* Each phase captures every unique initial `behavior` binding once before constructing tasks. Recovery
|
|
4021
4509
|
* validates those live tasks' captured callable handlers without rereading the registry, while the
|
|
4022
4510
|
* retained registry identity remains available to resolve future live additions at their mint time.
|
|
4023
4511
|
*
|
|
@@ -4038,22 +4526,22 @@ function createRecoveredWorkflow(snapshot, options) {
|
|
|
4038
4526
|
const owned = cloneWorkflowSnapshot(snapshot);
|
|
4039
4527
|
if (owned.override !== void 0 || owned.phases.some((phase) => phase.override !== void 0)) throw new WorkflowError("RESTORE", `workflow '${owned.id}' has a terminal override`, { workflow: owned.id });
|
|
4040
4528
|
const workflow = new Workflow(cloneWorkflowSnapshot(recoverWorkflowSnapshot(owned)), captured);
|
|
4041
|
-
if (!hasWorkflowHandlers(workflow)) throw new WorkflowError("RESTORE", `workflow '${owned.id}' has an unresolved
|
|
4529
|
+
if (!hasWorkflowHandlers(workflow)) throw new WorkflowError("RESTORE", `workflow '${owned.id}' has an unresolved behavior`, { workflow: owned.id });
|
|
4042
4530
|
return workflow;
|
|
4043
4531
|
}
|
|
4044
4532
|
/**
|
|
4045
|
-
*
|
|
4046
|
-
* {@link MemoryWorkflowStore} persisting {@link WorkflowSnapshot}s by workflow id, the
|
|
4047
|
-
* backend behind the W-d persistence seam.
|
|
4533
|
+
* Creates the in-memory durable {@link WorkflowStoreInterface} — a process-lifetime
|
|
4534
|
+
* {@link MemoryWorkflowStore} persisting {@link WorkflowSnapshot}s by workflow id, the default
|
|
4535
|
+
* backend behind the W-d persistence seam. It takes no options and expires nothing: a
|
|
4536
|
+
* persisted run state lives until an explicit `delete`.
|
|
4048
4537
|
*
|
|
4049
4538
|
* @remarks
|
|
4050
4539
|
* The snapshot analogue of the server package's `createMemorySessionStore`
|
|
4051
|
-
* (and the `createMemoryQueueStore` family)
|
|
4052
|
-
*
|
|
4053
|
-
* the zero-plumbing DEFAULT (a plain `Map`); its driver-pluggable twin is
|
|
4540
|
+
* (and the `createMemoryQueueStore` family) is the zero-plumbing default (a plain `Map`); its
|
|
4541
|
+
* driver-pluggable twin is
|
|
4054
4542
|
* {@link createDatabaseWorkflowStore} (the snapshot as one opaque JSON column over a `databases`
|
|
4055
|
-
* table) — for a
|
|
4056
|
-
* driver, and it swaps in
|
|
4543
|
+
* table) — for a durable store (run-state surviving a restart) pass it a JSON / SQLite / IndexedDB
|
|
4544
|
+
* driver, and it swaps in without touching the runner or the entity tree. Restore stays a caller
|
|
4057
4545
|
* concern: read a snapshot back and rebuild the live tree with {@link createRestoredWorkflow}.
|
|
4058
4546
|
*
|
|
4059
4547
|
* @returns A memory-backed {@link WorkflowStoreInterface}
|
|
@@ -4073,23 +4561,24 @@ function createMemoryWorkflowStore() {
|
|
|
4073
4561
|
return new MemoryWorkflowStore();
|
|
4074
4562
|
}
|
|
4075
4563
|
/**
|
|
4076
|
-
*
|
|
4564
|
+
* Creates a {@link DatabaseWorkflowStore} over any {@link DriverInterface} — the durable,
|
|
4077
4565
|
* driver-pluggable backing for the W-d persistence seam, the opt-in twin of
|
|
4078
|
-
* {@link createMemoryWorkflowStore}.
|
|
4566
|
+
* {@link createMemoryWorkflowStore}. It holds the snapshot as one opaque JSON column, and its
|
|
4567
|
+
* `driver` defaults to memory, so it works before any durable driver is passed.
|
|
4079
4568
|
*
|
|
4080
4569
|
* @remarks
|
|
4081
|
-
* Builds a one-table database (`snapshots`, keyed by `id`) over the supplied driver
|
|
4082
|
-
*
|
|
4570
|
+
* Builds a one-table database (`snapshots`, keyed by `id`) over the supplied driver. The column map
|
|
4571
|
+
* is `{ id; snapshot }` where `snapshot` is a
|
|
4083
4572
|
* `rawShape` (a JSON blob), exactly as `createDatabaseQueueStore` stores its `input`. The
|
|
4084
|
-
* snapshot is already a
|
|
4085
|
-
*
|
|
4573
|
+
* snapshot is already a complete, self-contained, pure-JSON payload, so storing it whole is lossless
|
|
4574
|
+
* and keeps the row type flat — a structured multi-column snapshot table would force the contract to
|
|
4086
4575
|
* `Infer` the deeply-nested snapshot shape (workflow → phases → tasks → results) and trip TS2589;
|
|
4087
|
-
* the opaque column sidesteps it (the column reads back as `unknown`, narrowed on `get` by
|
|
4088
|
-
* {@link
|
|
4089
|
-
* {@link
|
|
4090
|
-
* `
|
|
4576
|
+
* the opaque column sidesteps it (the column reads back as `unknown`, owned and narrowed on `get` by
|
|
4577
|
+
* {@link cloneWorkflowSnapshot}, whose semantic pass is
|
|
4578
|
+
* {@link import('./validators.js').isOwnedWorkflowSnapshot}). Pass a server `createJSONDriver` /
|
|
4579
|
+
* `createSQLiteDriver` (or a browser IndexedDB driver) for a persistent one —
|
|
4091
4580
|
* the durability is the driver's job, the store engine is shared. It swaps in behind
|
|
4092
|
-
* {@link WorkflowStoreInterface}
|
|
4581
|
+
* {@link WorkflowStoreInterface} without touching the runner or the entity tree.
|
|
4093
4582
|
*
|
|
4094
4583
|
* @param driver - The storage backend the snapshots persist to (defaults to {@link createMemoryDriver})
|
|
4095
4584
|
* @returns A {@link WorkflowStoreInterface} over the driver
|
|
@@ -4117,65 +4606,91 @@ function createDatabaseWorkflowStore(driver = createMemoryDriver()) {
|
|
|
4117
4606
|
}).table("snapshots"));
|
|
4118
4607
|
}
|
|
4119
4608
|
/**
|
|
4120
|
-
*
|
|
4121
|
-
* workflow tree by
|
|
4122
|
-
*
|
|
4609
|
+
* Creates the thin orchestrator — a {@link WorkflowRunnerInterface} — that executes a live W-b
|
|
4610
|
+
* workflow tree by composing the shipped substrate: phases sequential, tasks concurrent, each
|
|
4611
|
+
* task dispatched through its own resolved handler under the workflow's `bail` policy. The
|
|
4612
|
+
* engine is pure — it carries no behavior or provider registry, and its only option is the
|
|
4613
|
+
* scheduler it paces phase boundaries with.
|
|
4123
4614
|
*
|
|
4124
4615
|
* @remarks
|
|
4125
|
-
*
|
|
4126
|
-
*
|
|
4127
|
-
* resolved its own {@link import('./types.js').WorkflowFunction} into
|
|
4128
|
-
* {@link import('./types.js').TaskInterface.handler} ONCE at construction, from the
|
|
4616
|
+
* Each live task already resolved its own {@link import('./types.js').WorkflowFunction} into
|
|
4617
|
+
* {@link import('./types.js').TaskInterface.handler} once at construction, from the
|
|
4129
4618
|
* {@link WorkflowOptions.functions} registry supplied to `execute` / {@link createWorkflow}.
|
|
4130
4619
|
* Per-phase bounded concurrency is one {@link createRunner} per phase; `bail` maps onto that
|
|
4131
4620
|
* Runner's fail-fast (`true` — the first failure aborts the in-flight siblings + skips the
|
|
4132
4621
|
* rest) vs settle-all (`false` — failures are recorded, the run finishes); the run-level abort
|
|
4133
4622
|
* / timeout / budget ({@link import('./types.js').WorkflowRunOptions}) fold through
|
|
4134
4623
|
* `AbortSignal.any` (the agent runtime's pattern); pacing is the shipped scheduler.
|
|
4135
|
-
* `execute(definition, options?)`
|
|
4624
|
+
* `execute(definition, options?)` builds the live tree from the definition itself (through
|
|
4136
4625
|
* {@link createWorkflow} — one source of truth, returned in `WorkflowResult.workflow`), drives
|
|
4137
4626
|
* the live entity (`start` → `complete` / `fail`), and resolves a
|
|
4138
4627
|
* {@link import('./types.js').WorkflowResult}.
|
|
4139
4628
|
*
|
|
4140
4629
|
* External integrations remain application-owned: a caller wires an ordinary
|
|
4141
4630
|
* {@link import('./types.js').WorkflowFunction} into its own {@link WorkflowOptions.functions}
|
|
4142
|
-
* registry. Only a task that omits `
|
|
4631
|
+
* registry. Only a task that omits `behavior` auto-completes; unresolved named work is rejected
|
|
4143
4632
|
* before dispatch.
|
|
4144
4633
|
*
|
|
4145
4634
|
* @param options - An optional pacing `scheduler` (default the shipped cross-environment one).
|
|
4146
4635
|
* See {@link WorkflowRunnerOptions}.
|
|
4147
4636
|
* @returns A working {@link WorkflowRunnerInterface}
|
|
4148
4637
|
*
|
|
4149
|
-
* @example
|
|
4638
|
+
* @example Author a definition and run it
|
|
4150
4639
|
* ```ts
|
|
4151
4640
|
* import { createWorkflowRunner } from '@orkestrel/workflow'
|
|
4641
|
+
* import type { WorkflowDefinition } from '@orkestrel/workflow'
|
|
4642
|
+
*
|
|
4643
|
+
* const definition: WorkflowDefinition = {
|
|
4644
|
+
* id: 'release',
|
|
4645
|
+
* name: 'Release',
|
|
4646
|
+
* phases: [
|
|
4647
|
+
* {
|
|
4648
|
+
* id: 'build',
|
|
4649
|
+
* name: 'Build',
|
|
4650
|
+
* tasks: [
|
|
4651
|
+
* { id: 'compile', name: 'Compile', behavior: 'compile' },
|
|
4652
|
+
* { id: 'lint', name: 'Lint', behavior: 'lint' },
|
|
4653
|
+
* ],
|
|
4654
|
+
* },
|
|
4655
|
+
* {
|
|
4656
|
+
* id: 'ship',
|
|
4657
|
+
* name: 'Ship',
|
|
4658
|
+
* tasks: [{ id: 'publish', name: 'Publish', behavior: 'publish' }],
|
|
4659
|
+
* },
|
|
4660
|
+
* ],
|
|
4661
|
+
* }
|
|
4662
|
+
*
|
|
4663
|
+
* const runner = createWorkflowRunner() // a pure engine — no registries
|
|
4152
4664
|
*
|
|
4153
|
-
* const runner = createWorkflowRunner()
|
|
4154
|
-
* const definition = { id: 'w', name: 'W', phases: [{ id: 'p', name: 'P', tasks: [
|
|
4155
|
-
* { id: 't', name: 'T', run: 'compile' },
|
|
4156
|
-
* ] }] }
|
|
4157
4665
|
* const result = await runner.execute(definition, {
|
|
4158
|
-
* functions: {
|
|
4666
|
+
* functions: {
|
|
4667
|
+
* compile: async (controller) => `built ${controller.task.id}`,
|
|
4668
|
+
* lint: async () => 'clean',
|
|
4669
|
+
* publish: async () => 'published',
|
|
4670
|
+
* },
|
|
4159
4671
|
* })
|
|
4160
4672
|
* result.status // 'completed'
|
|
4161
|
-
* result.workflow.phase('
|
|
4673
|
+
* result.workflow.phase('build')?.task('compile')?.status // 'completed'
|
|
4674
|
+
* result.results // every settled task's TaskResult, in positional order
|
|
4162
4675
|
* ```
|
|
4163
4676
|
*/
|
|
4164
4677
|
function createWorkflowRunner(options) {
|
|
4165
4678
|
return new WorkflowRunner(options?.scheduler ?? createScheduler());
|
|
4166
4679
|
}
|
|
4167
4680
|
/**
|
|
4168
|
-
*
|
|
4681
|
+
* Creates a {@link WorkflowManagerInterface} — the store-backed registry of
|
|
4169
4682
|
* {@link WorkflowInterface}s, the additive manager tier mirroring the `@orkestrel/agent`
|
|
4170
|
-
* line's `createConversationManager` / `createWorkspaceManager`.
|
|
4683
|
+
* line's `createConversationManager` / `createWorkspaceManager`. The returned registry makes
|
|
4684
|
+
* hydrated named work runnable when `options.functions` is supplied, and leaves it
|
|
4685
|
+
* inspectable when it is not.
|
|
4171
4686
|
*
|
|
4172
4687
|
* @remarks
|
|
4173
|
-
* `options.functions` flows into every workflow the manager mints (`add`,
|
|
4174
|
-
* {@link createWorkflow}) or hydrates (`open`'s registry-miss path,
|
|
4175
|
-
* {@link createRestoredWorkflow}), so a hydrated workflow is
|
|
4176
|
-
* mirror. `options.store` is the
|
|
4688
|
+
* `options.functions` flows into every workflow the manager mints (`add`, through
|
|
4689
|
+
* {@link createWorkflow}) or hydrates (`open`'s registry-miss path, through
|
|
4690
|
+
* {@link createRestoredWorkflow}), so a hydrated workflow is runnable rather than a dead snapshot
|
|
4691
|
+
* mirror. `options.store` is the exact analogue of the twins' `store` seam — omitted ⇒ the
|
|
4177
4692
|
* manager is registry-only (`open` resolves only what is registered, `save` is a no-op). This
|
|
4178
|
-
* is
|
|
4693
|
+
* is purely additive: direct {@link WorkflowStoreInterface} use and
|
|
4179
4694
|
* {@link createRestoredWorkflow} remain valid — the manager is one more caller-driven persistence
|
|
4180
4695
|
* seam, not a replacement.
|
|
4181
4696
|
*
|
|
@@ -4190,7 +4705,7 @@ function createWorkflowRunner(options) {
|
|
|
4190
4705
|
* store: createMemoryWorkflowStore(),
|
|
4191
4706
|
* functions: { compile: async (controller) => `built ${controller.task.id}` },
|
|
4192
4707
|
* })
|
|
4193
|
-
* const workflow = manager.add(definition) // minted, registered,
|
|
4708
|
+
* const workflow = manager.add(definition) // minted, registered, runnable
|
|
4194
4709
|
* await manager.save(workflow.id) // persisted to the store
|
|
4195
4710
|
* const reopened = await manager.open(workflow.id) // already registered — no store hit
|
|
4196
4711
|
* ```
|
|
@@ -4199,12 +4714,12 @@ function createWorkflowManager(options) {
|
|
|
4199
4714
|
return new WorkflowManager(options);
|
|
4200
4715
|
}
|
|
4201
4716
|
/**
|
|
4202
|
-
*
|
|
4717
|
+
* Creates the safe cross-environment cooperative-yield default — a
|
|
4203
4718
|
* {@link SchedulerInterface} built on `setTimeout` / `clearTimeout` alone, so it
|
|
4204
4719
|
* runs unchanged in both the browser and Node.
|
|
4205
4720
|
*
|
|
4206
4721
|
* @remarks
|
|
4207
|
-
* `yield()` gives the host a turn
|
|
4722
|
+
* `yield()` gives the host a turn through a zero-delay macrotask (so pending I/O,
|
|
4208
4723
|
* timers, and rendering actually run — a microtask would not); `delay(ms)` resumes
|
|
4209
4724
|
* after at least `ms`. Pass `options.signal` to make a pending yield/delay reject
|
|
4210
4725
|
* with the signal's exact `reason`; the shared owned-signal lifecycle clears the timer
|
|
@@ -4245,25 +4760,26 @@ function createScheduler() {
|
|
|
4245
4760
|
return new Scheduler();
|
|
4246
4761
|
}
|
|
4247
4762
|
/**
|
|
4248
|
-
*
|
|
4249
|
-
* `spawn` — through a bounded-concurrency queue, collecting their results in order
|
|
4763
|
+
* Creates a thin generic orchestrator that drives declared units — and any they
|
|
4764
|
+
* `spawn` — through a bounded-concurrency queue, collecting their results in order and
|
|
4765
|
+
* failing the run fast on the first genuine unit failure.
|
|
4250
4766
|
*
|
|
4251
4767
|
* @remarks
|
|
4252
4768
|
* The Runner composes the workers `Queue` for backpressure, FIFO ordering, bounded
|
|
4253
4769
|
* concurrency, retries, and the per-attempt timeout — it adds only orchestration, not
|
|
4254
|
-
* a second concurrency engine. `execute(inputs)` runs the unit set
|
|
4770
|
+
* a second concurrency engine. `execute(inputs)` runs the unit set once (a second call
|
|
4255
4771
|
* throws) and resolves the units' results in order: the declared inputs first, then
|
|
4256
4772
|
* any `spawn`ed siblings in spawn order. Each unit's handler gets a `Controller` — its
|
|
4257
4773
|
* `id` / `input`, a `signal` that fires on the unit's `abort`, a runner-level `abort`,
|
|
4258
4774
|
* or the attempt's timeout, a promise-parked `wait()`, and `spawn(input)` to fan out
|
|
4259
4775
|
* sibling units. The run is **fail-fast**: the first unit failure (after retries)
|
|
4260
|
-
* aborts every other unit and rejects `execute` with that error. **Observable
|
|
4776
|
+
* aborts every other unit and rejects `execute` with that error. **Observable:** a
|
|
4261
4777
|
* typed `emitter` surfaces `start` / `unit` / `spawn` / `settle` / `fail` / `finish` / `abort`.
|
|
4262
4778
|
*
|
|
4263
|
-
* Because `spawn` is fire-and-track (the runner awaits the whole spawn closure
|
|
4264
|
-
* outstanding-unit count, not a one-time snapshot), a handler need
|
|
4265
|
-
* for them to run — and on a bounded runner
|
|
4266
|
-
*
|
|
4779
|
+
* Because `spawn` is fire-and-track (the runner awaits the whole spawn closure through an
|
|
4780
|
+
* outstanding-unit count, not a one-time snapshot), a handler need not await its spawns
|
|
4781
|
+
* for them to run — and on a bounded runner do not `await` a spawn inline (a slot-holding
|
|
4782
|
+
* handler awaiting its own spawn can deadlock); fan out and return instead.
|
|
4267
4783
|
*
|
|
4268
4784
|
* @typeParam TInput - The work input each unit carries
|
|
4269
4785
|
* @typeParam TResult - The value a unit's handler resolves
|
|
@@ -4292,6 +4808,6 @@ function createRunner(options) {
|
|
|
4292
4808
|
return new Runner(options);
|
|
4293
4809
|
}
|
|
4294
4810
|
//#endregion
|
|
4295
|
-
export {
|
|
4811
|
+
export { Collection, DEFAULT_BAIL, DEFAULT_PHASE_CONCURRENCY, DatabaseWorkflowStore, LIFECYCLE_STATUSES, MAX_TIMER_MS, MemoryWorkflowStore, PERSISTED_NODE_EVENTS, PERSISTED_TASK_EVENTS, PhaseManager, RunHolder, Runner, Scheduler, TASK_TRANSITIONS, TERMINAL_STATUSES, TaskManager, Workflow, WorkflowError, WorkflowManager, WorkflowPersistence, WorkflowRunner, buildPhaseContext, buildTaskContext, buildWorkflowContext, canTransitionTask, captureWorkflowOptions, cloneTaskActivity, cloneTaskClaims, cloneWorkflowSnapshot, collectResults, createDatabaseWorkflowStore, createMemoryWorkflowStore, createRecoveredWorkflow, createRestoredWorkflow, createRunner, createScheduler, createWorkflow, createWorkflowContract, createWorkflowManager, createWorkflowRunner, createWorkflowTree, definitionToSnapshot, delayHost, deriveBoundary, derivePhaseStatus, deriveWorkflowStatus, errorToMessage, failure, findFailure, hasWorkflowHandlers, insertEntry, isCompletable, isHalted, isLifecycleStatus, isOwnedWorkflowSnapshot, isSkipping, isStoppable, isTaskActivity, isTaskActivityInput, isTaskClaimList, isTaskFailure, isTaskResult, isTerminalStatus, isWorkflowError, isWorkflowInterface, isWorkflowSnapshot, matchesDescription, moveEntry, ownsAttempt, parkSignal, phaseDefinitionToSnapshot, phaseShape, phaseUpdateShape, recoverWorkflowSnapshot, resolveTaskSilence, scanSnapshotContext, scheduleHost, success, taskDefinitionToSnapshot, taskShape, taskUpdateShape, workflowShape };
|
|
4296
4812
|
|
|
4297
4813
|
//# sourceMappingURL=index.js.map
|