@jarenjs/flow 0.72.3 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,288 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://jarenjs.dev/schemas/jaren-workflow/0.2",
4
+ "title": "Jaren composed workflow 0.2",
5
+ "description": "Nested work, choices, waits and bounded control lower onto jaren-fsm 0.2 and jaren-dag 0.1. Distinct from the historical Mermaid jaren-workflow 0.1 projection. Every automatic cycle must cross a declared visit limit; every task needs an implementation version.",
6
+ "type": "object",
7
+ "properties": {
8
+ "$workflow": {
9
+ "const": "0.2"
10
+ },
11
+ "revision": {
12
+ "type": "string",
13
+ "minLength": 1
14
+ },
15
+ "initial": {
16
+ "type": "string",
17
+ "minLength": 1
18
+ },
19
+ "states": {
20
+ "type": "object",
21
+ "minProperties": 1,
22
+ "propertyNames": {
23
+ "type": "string",
24
+ "minLength": 1
25
+ },
26
+ "additionalProperties": {
27
+ "$ref": "#/$defs/state"
28
+ }
29
+ }
30
+ },
31
+ "required": [
32
+ "$workflow",
33
+ "revision",
34
+ "initial",
35
+ "states"
36
+ ],
37
+ "additionalProperties": false,
38
+ "$defs": {
39
+ "queryDocument": {
40
+ "$ref": "https://jarenjs.dev/schemas/jaren-query/0.1"
41
+ },
42
+ "dagDocument": {
43
+ "$ref": "https://jarenjs.dev/schemas/jaren-dag/0.1"
44
+ },
45
+ "body": {
46
+ "type": "object",
47
+ "properties": {
48
+ "initial": {
49
+ "type": "string",
50
+ "minLength": 1
51
+ },
52
+ "states": {
53
+ "type": "object",
54
+ "minProperties": 1,
55
+ "propertyNames": {
56
+ "type": "string",
57
+ "minLength": 1
58
+ },
59
+ "additionalProperties": {
60
+ "$ref": "#/$defs/state"
61
+ }
62
+ }
63
+ },
64
+ "required": [
65
+ "initial",
66
+ "states"
67
+ ],
68
+ "additionalProperties": false
69
+ },
70
+ "state": {
71
+ "anyOf": [
72
+ {
73
+ "type": "object",
74
+ "properties": {
75
+ "work": {
76
+ "anyOf": [
77
+ {
78
+ "type": "object",
79
+ "properties": {
80
+ "task": {
81
+ "type": "string",
82
+ "minLength": 1
83
+ },
84
+ "version": {
85
+ "type": "string",
86
+ "minLength": 1
87
+ },
88
+ "with": {
89
+ "$ref": "#/$defs/queryDocument"
90
+ }
91
+ },
92
+ "required": [
93
+ "task",
94
+ "version"
95
+ ],
96
+ "additionalProperties": false
97
+ },
98
+ {
99
+ "type": "object",
100
+ "properties": {
101
+ "dag": {
102
+ "$ref": "#/$defs/dagDocument"
103
+ }
104
+ },
105
+ "required": [
106
+ "dag"
107
+ ],
108
+ "additionalProperties": false
109
+ }
110
+ ]
111
+ },
112
+ "input": {
113
+ "$ref": "#/$defs/queryDocument"
114
+ },
115
+ "then": {
116
+ "type": "string",
117
+ "minLength": 1
118
+ },
119
+ "catch": {
120
+ "type": "string",
121
+ "minLength": 1
122
+ },
123
+ "limit": {
124
+ "type": "integer",
125
+ "minimum": 1,
126
+ "maximum": 9007199254740991
127
+ }
128
+ },
129
+ "required": [
130
+ "work",
131
+ "then"
132
+ ],
133
+ "additionalProperties": false
134
+ },
135
+ {
136
+ "type": "object",
137
+ "properties": {
138
+ "choose": {
139
+ "type": "array",
140
+ "items": {
141
+ "type": "object",
142
+ "properties": {
143
+ "guard": {
144
+ "$ref": "#/$defs/queryDocument"
145
+ },
146
+ "to": {
147
+ "type": "string",
148
+ "minLength": 1
149
+ }
150
+ },
151
+ "required": [
152
+ "guard",
153
+ "to"
154
+ ],
155
+ "additionalProperties": false
156
+ },
157
+ "minItems": 1
158
+ },
159
+ "otherwise": {
160
+ "type": "string",
161
+ "minLength": 1
162
+ },
163
+ "limit": {
164
+ "type": "integer",
165
+ "minimum": 1,
166
+ "maximum": 9007199254740991
167
+ }
168
+ },
169
+ "required": [
170
+ "choose",
171
+ "otherwise"
172
+ ],
173
+ "additionalProperties": false
174
+ },
175
+ {
176
+ "type": "object",
177
+ "properties": {
178
+ "on": {
179
+ "type": "array",
180
+ "items": {
181
+ "type": "object",
182
+ "properties": {
183
+ "event": {
184
+ "type": "string",
185
+ "minLength": 1
186
+ },
187
+ "to": {
188
+ "type": "string",
189
+ "minLength": 1
190
+ },
191
+ "guard": {
192
+ "$ref": "#/$defs/queryDocument"
193
+ }
194
+ },
195
+ "required": [
196
+ "event",
197
+ "to"
198
+ ],
199
+ "additionalProperties": false
200
+ },
201
+ "minItems": 0
202
+ },
203
+ "after": {
204
+ "type": "object",
205
+ "properties": {
206
+ "ms": {
207
+ "type": "number",
208
+ "minimum": 0
209
+ },
210
+ "to": {
211
+ "type": "string",
212
+ "minLength": 1
213
+ }
214
+ },
215
+ "required": [
216
+ "ms",
217
+ "to"
218
+ ],
219
+ "additionalProperties": false
220
+ },
221
+ "limit": {
222
+ "type": "integer",
223
+ "minimum": 1,
224
+ "maximum": 9007199254740991
225
+ }
226
+ },
227
+ "required": [
228
+ "on"
229
+ ],
230
+ "additionalProperties": false,
231
+ "anyOf": [
232
+ {
233
+ "properties": {
234
+ "on": {
235
+ "minItems": 1
236
+ }
237
+ }
238
+ },
239
+ {
240
+ "required": [
241
+ "after"
242
+ ]
243
+ }
244
+ ]
245
+ },
246
+ {
247
+ "type": "object",
248
+ "properties": {
249
+ "flow": {
250
+ "$ref": "#/$defs/body"
251
+ },
252
+ "then": {
253
+ "type": "string",
254
+ "minLength": 1
255
+ },
256
+ "limit": {
257
+ "type": "integer",
258
+ "minimum": 1,
259
+ "maximum": 9007199254740991
260
+ }
261
+ },
262
+ "required": [
263
+ "flow",
264
+ "then"
265
+ ],
266
+ "additionalProperties": false
267
+ },
268
+ {
269
+ "type": "object",
270
+ "properties": {
271
+ "final": {
272
+ "const": true
273
+ },
274
+ "limit": {
275
+ "type": "integer",
276
+ "minimum": 1,
277
+ "maximum": 9007199254740991
278
+ }
279
+ },
280
+ "required": [
281
+ "final"
282
+ ],
283
+ "additionalProperties": false
284
+ }
285
+ ]
286
+ }
287
+ }
288
+ }
package/src/dag.js CHANGED
@@ -57,9 +57,9 @@ function compileEmbedded(compile, embedded, docPath) {
57
57
  * value, `complete` records the run's result. Any member may return a
58
58
  * promise; a throwing store fails the run (JF2009), never silently.
59
59
  * @typedef {Object} DagCheckpointStore
60
- * @property {(runId: string) => any} load
61
- * @property {(runId: string, nodeId: string, value: any) => any} save
62
- * @property {(runId: string, result: any) => any} complete
60
+ * @property {(runId: string, identity?: any) => any} load
61
+ * @property {(runId: string, nodeId: string, value: any, identity?: any) => any} save
62
+ * @property {(runId: string, result: any, identity?: any) => any} complete
63
63
  */
64
64
 
65
65
  /**
@@ -71,7 +71,7 @@ function compileEmbedded(compile, embedded, docPath) {
71
71
  * declared task identity this workflow depends on, keyed by node id and
72
72
  * SORTED (§7.8); a nested workflow's map composes under its node's
73
73
  * path. Empty when no node declares a version.
74
- * @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string }) => Promise<any>} run -
74
+ * @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string, drainOnAbort?: boolean }) => Promise<any>} run -
75
75
  * Execute the graph for one input (`undefined` reads as `null`).
76
76
  */
77
77
 
@@ -124,7 +124,7 @@ function normalizeTaskEntry(entry, name) {
124
124
  * @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
125
125
  * | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
126
126
  * taskVersions?: Record<string, string> }>,
127
- * checkpoint?: DagCheckpointStore }} [options]
127
+ * checkpoint?: DagCheckpointStore, revision?: string }} [options]
128
128
  * @returns {CompiledDag}
129
129
  * @throws {FlowCompileError} when the document violates the format (JF0xxx)
130
130
  * @throws {TypeError} when the options are malformed (a registry that is
@@ -137,6 +137,9 @@ export function compileDag(doc, options) {
137
137
  throw new TypeError('compileDag: "tasks" must be an object of handler functions');
138
138
  }
139
139
  const checkpoint = options?.checkpoint;
140
+ const revision = options?.revision;
141
+ if (revision !== undefined && (typeof revision !== 'string' || !revision.trim() || !checkpoint))
142
+ throw new TypeError('compileDag: revision must be nonblank and requires a checkpoint store');
140
143
  if (checkpoint !== undefined && (typeof checkpoint?.load !== 'function'
141
144
  || typeof checkpoint.save !== 'function'
142
145
  || typeof checkpoint.complete !== 'function')) {
@@ -262,6 +265,9 @@ export function compileDag(doc, options) {
262
265
  `a dag declares exactly one output node (found ${outputs.length})`, '/nodes');
263
266
  }
264
267
  const outputId = outputs[0];
268
+ // Exact canonical provenance is opt-in for legacy stores. It is compared
269
+ // before any saved node value can enter the memo, not merely handed to a host.
270
+ const documentIdentity = revision === undefined ? null : canonicalizeJson(doc);
265
271
 
266
272
  if (!Array.isArray(doc.edges)) {
267
273
  throw new FlowCompileError('JF0012',
@@ -393,6 +399,8 @@ export function compileDag(doc, options) {
393
399
  throw new TypeError('run: "signal" must be an AbortSignal');
394
400
  }
395
401
  const onNode = opts?.onNode;
402
+ if (opts?.drainOnAbort !== undefined && typeof opts.drainOnAbort !== 'boolean')
403
+ throw new TypeError('run: drainOnAbort must be boolean');
396
404
  if (onNode !== undefined && typeof onNode !== 'function') {
397
405
  throw new TypeError('run: "onNode" must be a function');
398
406
  }
@@ -405,7 +413,7 @@ export function compileDag(doc, options) {
405
413
  throw new TypeError(
406
414
  'run: a checkpointed dag needs a non-empty string "runId" to persist under');
407
415
  }
408
- return execute(input === undefined ? null : input, signal, onNode, runId);
416
+ return execute(input === undefined ? null : input, signal, onNode, runId, opts?.drainOnAbort === true);
409
417
  }
410
418
 
411
419
  /**
@@ -413,11 +421,17 @@ export function compileDag(doc, options) {
413
421
  * @param {AbortSignal|undefined} signal
414
422
  * @param {((record: DagNodeRecord) => void)|undefined} onNode
415
423
  * @param {string|undefined} runId
424
+ * @param {boolean} drainOnAbort
416
425
  */
417
- async function execute(runInput, signal, onNode, runId) {
426
+ async function execute(runInput, signal, onNode, runId, drainOnAbort) {
418
427
  const controller = new AbortController();
419
428
  /** @type {FlowRuntimeError|null} */
420
429
  let failure = null;
430
+ let rejectAborted;
431
+ const aborted = new Promise((_, reject) => { rejectAborted = reject; });
432
+ aborted.catch(() => {});
433
+ const identity = revision === undefined ? undefined
434
+ : Object.freeze({ revision, document: documentIdentity, input: canonicalizeJson(runInput), taskVersions });
421
435
 
422
436
  /** @param {DagNodeRecord} rec */
423
437
  const record = (rec) => {
@@ -437,6 +451,7 @@ export function compileDag(doc, options) {
437
451
  if (failure === null) {
438
452
  failure = err;
439
453
  controller.abort();
454
+ rejectAborted(err);
440
455
  }
441
456
  };
442
457
 
@@ -463,11 +478,17 @@ export function compileDag(doc, options) {
463
478
  if (checkpoint !== undefined && runId !== undefined) {
464
479
  let loaded;
465
480
  try {
466
- loaded = await checkpoint.load(runId);
481
+ loaded = await Promise.race([checkpoint.load(runId, identity), aborted]);
482
+ if (identity !== undefined && loaded != null
483
+ && canonicalizeJson(loaded.identity ?? null) !== canonicalizeJson(identity)) {
484
+ throw new FlowRuntimeError('JF2013', `checkpoint '${runId}' has different or missing provenance`);
485
+ }
467
486
  }
468
487
  catch (err) {
469
488
  if (signal !== undefined && onAbort !== null)
470
489
  signal.removeEventListener('abort', onAbort);
490
+ if (failure !== null) throw failure;
491
+ if (err instanceof FlowRuntimeError && err.code === 'JF2013') throw err;
471
492
  const cause = asError(err);
472
493
  throw new FlowRuntimeError('JF2009',
473
494
  `the checkpoint store failed to load run '${runId}': ${cause.message}`,
@@ -550,6 +571,7 @@ export function compileDag(doc, options) {
550
571
  }
551
572
  default: value = null; break;
552
573
  }
574
+ if (failure !== null) throw failure;
553
575
  if (node.checkpoint && checkpoint !== undefined && runId !== undefined) {
554
576
  // the explicit serialization contract (§7.6): the node
555
577
  // DECLARED its output JSON; a value that is not fails the
@@ -564,7 +586,7 @@ export function compileDag(doc, options) {
564
586
  + `not JSON-serializable: ${cause.message}`, node.docPath, cause);
565
587
  }
566
588
  try {
567
- await checkpoint.save(runId, node.id, value);
589
+ await Promise.race([checkpoint.save(runId, node.id, value, identity), aborted]);
568
590
  }
569
591
  catch (err) {
570
592
  const cause = asError(err);
@@ -573,6 +595,7 @@ export function compileDag(doc, options) {
573
595
  node.docPath, cause);
574
596
  }
575
597
  }
598
+ if (failure !== null) throw failure;
576
599
  settle('ok');
577
600
  return value;
578
601
  }
@@ -603,9 +626,28 @@ export function compileDag(doc, options) {
603
626
  });
604
627
 
605
628
  try {
606
- await Promise.all(all);
629
+ await Promise.race([Promise.all(all), aborted]);
630
+ if (failure !== null) throw failure;
631
+ const result = await promises.get(outputId);
632
+ if (checkpoint !== undefined && runId !== undefined) {
633
+ try {
634
+ await Promise.race([checkpoint.complete(runId, result, identity), aborted]);
635
+ }
636
+ catch (err) {
637
+ if (failure !== null) throw failure;
638
+ const cause = asError(err);
639
+ throw new FlowRuntimeError('JF2009',
640
+ `the checkpoint store failed to complete run '${runId}': ${cause.message}`,
641
+ '', cause);
642
+ }
643
+ }
644
+ return result;
607
645
  }
608
646
  catch (err) {
647
+ // A worker's shutdown report accounts for actual task lifetimes. It
648
+ // owns a separate grace deadline, so it may ask us to retain the run
649
+ // until ignoring handlers settle instead of reporting early drainage.
650
+ if (drainOnAbort && failure?.code === 'JF2007') await Promise.allSettled(all);
609
651
  throw failure ?? err;
610
652
  }
611
653
  finally {
@@ -613,20 +655,6 @@ export function compileDag(doc, options) {
613
655
  signal.removeEventListener('abort', onAbort);
614
656
  }
615
657
  }
616
- if (failure !== null) throw failure;
617
- const result = await promises.get(outputId);
618
- if (checkpoint !== undefined && runId !== undefined) {
619
- try {
620
- await checkpoint.complete(runId, result);
621
- }
622
- catch (err) {
623
- const cause = asError(err);
624
- throw new FlowRuntimeError('JF2009',
625
- `the checkpoint store failed to complete run '${runId}': ${cause.message}`,
626
- '', cause);
627
- }
628
- }
629
- return result;
630
658
  }
631
659
 
632
660
  // The canonical version map: every declared task identity this
package/src/errors.js CHANGED
@@ -37,6 +37,9 @@ export const FLOW_CODES = Object.freeze({
37
37
  JF0017: 'the document does not declare exactly one output node',
38
38
  JF0018: 'a task node names a handler the registry does not provide',
39
39
  JF0019: 'a task node and its registered handler disagree about the handler version',
40
+ JF0020: 'a statechart declaration violates hierarchy or transition rules',
41
+ JF0021: 'a workflow declaration or reference is malformed',
42
+ JF0022: 'an automatic workflow cycle has no declared visit bound',
40
43
  JF2001: 'a state id the machine does not declare',
41
44
  JF2002: 'step was called with a non-string event',
42
45
  JF2003: 'a guard threw while evaluating',
@@ -46,6 +49,13 @@ export const FLOW_CODES = Object.freeze({
46
49
  JF2007: 'the caller signal aborted the run',
47
50
  JF2008: 'a declared checkpoint value is not JSON-serializable',
48
51
  JF2009: 'the checkpoint store failed',
52
+ JF2010: 'a statechart snapshot is malformed or has an illegal configuration',
53
+ JF2011: 'explicit time moved backwards or a timer exceeded its numeric range',
54
+ JF2012: 'a statechart exceeded its microstep limit',
55
+ JF2013: 'a checkpoint belongs to a different workflow, input or task implementation',
56
+ JF2014: 'a workflow run is busy or its snapshot generation is stale',
57
+ JF2015: 'a workflow state exceeded its declared visit bound',
58
+ JF2016: 'a workflow snapshot is malformed or execution could not progress',
49
59
  });
50
60
 
51
61
  /**
package/src/fsm.js CHANGED
@@ -33,7 +33,7 @@ import { asError, FlowCompileError, FlowRuntimeError } from './errors.js';
33
33
  * @param {string} docPath - JSON Pointer of the list member.
34
34
  * @returns {CompiledEffect[]}
35
35
  */
36
- function compileEffectList(list, docPath) {
36
+ export function compileEffectList(list, docPath) {
37
37
  if (list === undefined) return [];
38
38
  if (!Array.isArray(list)) {
39
39
  throw new FlowCompileError('JF0008',
@@ -70,7 +70,7 @@ function compileEffectList(list, docPath) {
70
70
  * @param {any[]} out - resolved descriptors, appended to
71
71
  * @param {any[]} errors - step-result error records, appended to
72
72
  */
73
- function resolveEffect(effect, scope, out, errors) {
73
+ export function resolveEffect(effect, scope, out, errors) {
74
74
  if (effect.with === null) {
75
75
  out.push({ run: effect.run });
76
76
  return;
package/src/index.js CHANGED
@@ -9,5 +9,16 @@
9
9
  export { compileFsm, createFsmSession } from './fsm.js';
10
10
  export { fsmToApp, fsmStateSchema } from './app.js';
11
11
  export { compileDag } from './dag.js';
12
+ export { compileStatechart, createStatechartSession } from './statechart.js';
13
+ export { lowerWorkflow, compileWorkflow } from './workflow.js';
12
14
  export { snapshotFsm, resumeFsmSession, createDurableFsmSession } from './persist.js';
13
15
  export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
16
+
17
+ /** @typedef {import('./statechart.js').StatechartState} StatechartState */
18
+ /** @typedef {import('./statechart.js').StatechartResult} StatechartResult */
19
+ /** @typedef {import('./statechart.js').CompiledStatechart} CompiledStatechart */
20
+ /** @typedef {import('./workflow.js').LoweredWorkflow} LoweredWorkflow */
21
+ /** @typedef {import('./workflow.js').WorkflowSnapshot} WorkflowSnapshot */
22
+ /** @typedef {import('./workflow.js').WorkflowStore} WorkflowStore */
23
+ /** @typedef {import('./workflow.js').WorkflowResult} WorkflowResult */
24
+ /** @typedef {import('./workflow.js').CompiledWorkflow} CompiledWorkflow */