@jarenjs/flow 0.75.0 → 0.83.3
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 +9 -0
- package/dist/types/dag.d.ts +5 -4
- package/dist/types/effects.d.ts +25 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/ingest.d.ts +48 -0
- package/dist/types/runs.d.ts +28 -0
- package/dist/types/workflow.d.ts +2 -1
- package/docs/WORKFLOW-FORMAT.md +92 -0
- package/package.json +3 -3
- package/src/dag.js +7 -6
- package/src/effects.js +77 -0
- package/src/index.js +3 -0
- package/src/ingest.js +104 -0
- package/src/runs.js +68 -0
- package/src/workflow.js +9 -4
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @jarenjs/flow
|
|
2
2
|
|
|
3
|
+
Complete ingestion and durable domain runs reuse the existing workflow engine and injected persistence/job fences. See the [combined adoption recipe](../../docs/ADOPTION-EVIDENCE.md) for restart and no-op proofs; real provider read-back and operator reconciliation remain host responsibilities.
|
|
4
|
+
|
|
3
5
|
Executable JSON workflows: flat FSMs, statecharts, DAGs, and a composition
|
|
4
6
|
compiler that combines long-lived control with concurrent work regions.
|
|
5
7
|
The **jaren-fsm 0.1 format**
|
|
@@ -345,3 +347,10 @@ Every subpath a consumer can import, derived from the manifest by
|
|
|
345
347
|
Unit tests live in `test/flow/` at the repository root
|
|
346
348
|
(`npm run test:flow`). See the repository [README](../../README.md) for
|
|
347
349
|
the full suite documentation.
|
|
350
|
+
|
|
351
|
+
Bounded provider ingestion composes the workflow engine with injected page and
|
|
352
|
+
transaction capabilities. `createIngestion` resumes source/version/partition
|
|
353
|
+
checkpoints and publishes only complete coverage; see the
|
|
354
|
+
[ingestion contract](docs/WORKFLOW-FORMAT.md#complete-provider-ingestion).
|
|
355
|
+
Run-scoped `resources` reach tasks separately from JSON checkpoints and drain
|
|
356
|
+
before release.
|
package/dist/types/dag.d.ts
CHANGED
|
@@ -47,6 +47,7 @@ export type CompiledDag = {
|
|
|
47
47
|
onNode?: (record: DagNodeRecord) => void;
|
|
48
48
|
runId?: string;
|
|
49
49
|
drainOnAbort?: boolean;
|
|
50
|
+
resources?: unknown;
|
|
50
51
|
}) => Promise<any>;
|
|
51
52
|
};
|
|
52
53
|
/**
|
|
@@ -56,8 +57,8 @@ export type CompiledDag = {
|
|
|
56
57
|
* registry resolution — `run` only executes closures.
|
|
57
58
|
*
|
|
58
59
|
* @param {any} doc - the jaren-dag document
|
|
59
|
-
* @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
|
|
60
|
-
* | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
|
|
60
|
+
* @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal, resources?: any) => any)
|
|
61
|
+
* | { run: (props: { with: any, input: any }, signal: AbortSignal, resources?: any) => any, version?: string,
|
|
61
62
|
* taskVersions?: Record<string, string> }>,
|
|
62
63
|
* checkpoint?: DagCheckpointStore, revision?: string }} [options]
|
|
63
64
|
* @returns {CompiledDag}
|
|
@@ -70,11 +71,11 @@ export declare function compileDag(doc: any, options?: {
|
|
|
70
71
|
tasks?: Record<string, ((props: {
|
|
71
72
|
with: any;
|
|
72
73
|
input: any;
|
|
73
|
-
}, signal: AbortSignal) => any) | {
|
|
74
|
+
}, signal: AbortSignal, resources?: any) => any) | {
|
|
74
75
|
run: (props: {
|
|
75
76
|
with: any;
|
|
76
77
|
input: any;
|
|
77
|
-
}, signal: AbortSignal) => any;
|
|
78
|
+
}, signal: AbortSignal, resources?: any) => any;
|
|
78
79
|
version?: string;
|
|
79
80
|
taskVersions?: Record<string, string>;
|
|
80
81
|
}>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider executor is single-attempt for this composition. Its dispatch
|
|
3
|
+
* hook persists intent after scheduling/authority checks and before transport.
|
|
4
|
+
* Every ambiguous result stays unresolved; read-back/operator reconciliation is
|
|
5
|
+
* explicit. A new worker may inspect the record but cannot blindly resend it.
|
|
6
|
+
* @param {{ store: any, executor: any, authorize: Function, classify: Function }} options
|
|
7
|
+
*/
|
|
8
|
+
export declare function createExternalEffects(options: {
|
|
9
|
+
store: any;
|
|
10
|
+
executor: any;
|
|
11
|
+
authorize: Function;
|
|
12
|
+
classify: Function;
|
|
13
|
+
}): Readonly<{
|
|
14
|
+
run: (id: string, resources: {
|
|
15
|
+
lease: any;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}) => Promise<any>;
|
|
18
|
+
/** Existing queue worker binding. Unresolved/refused operations pause in the
|
|
19
|
+
* queue's cancelled state, which can be explicitly requeued for reconciliation.
|
|
20
|
+
* They must not become completed jobs whose identity cannot be claimed again.
|
|
21
|
+
* @param {{ operationId: string }} payload @param {any} context */
|
|
22
|
+
handler(payload: {
|
|
23
|
+
operationId: string;
|
|
24
|
+
}, context: any): Promise<any>;
|
|
25
|
+
}>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { fsmToApp, fsmStateSchema } from './app.js';
|
|
|
9
9
|
export { compileDag } from './dag.js';
|
|
10
10
|
export { compileStatechart, createStatechartSession } from './statechart.js';
|
|
11
11
|
export { lowerWorkflow, compileWorkflow } from './workflow.js';
|
|
12
|
+
export { createIngestion } from './ingest.js';
|
|
12
13
|
export { snapshotFsm, resumeFsmSession, createDurableFsmSession } from './persist.js';
|
|
13
14
|
export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
|
|
14
15
|
export type StatechartState = import('./statechart.js').StatechartState;
|
|
@@ -27,3 +28,5 @@ export type CompiledWorkflow = import('./workflow.js').CompiledWorkflow;
|
|
|
27
28
|
/** @typedef {import('./workflow.js').WorkflowStore} WorkflowStore */
|
|
28
29
|
/** @typedef {import('./workflow.js').WorkflowResult} WorkflowResult */
|
|
29
30
|
/** @typedef {import('./workflow.js').CompiledWorkflow} CompiledWorkflow */
|
|
31
|
+
export { createExternalEffects } from './effects.js';
|
|
32
|
+
export { createDomainRun } from './runs.js';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type IngestionPlan = {
|
|
2
|
+
source: string;
|
|
3
|
+
version: string;
|
|
4
|
+
generation: string;
|
|
5
|
+
partitions: string[];
|
|
6
|
+
input: any;
|
|
7
|
+
policyRevision: string;
|
|
8
|
+
consistency: 'snapshot' | 'revision';
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ source: string, version: string, generation: string,
|
|
12
|
+
* partitions: string[], input: any, policyRevision: string,
|
|
13
|
+
* consistency: 'snapshot' | 'revision' }} IngestionPlan
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Compose the existing workflow engine with bounded provider pages and atomic
|
|
17
|
+
* staging. source(plan, phase) proves either a stable source snapshot or a
|
|
18
|
+
* monotonic source revision. Local generation numbers are never that proof.
|
|
19
|
+
* @param {{ provider: { pages: (input: any, context: any) => AsyncIterable<any> },
|
|
20
|
+
* store: { begin: Function, stage: Function, invalidate: Function, publish: Function },
|
|
21
|
+
* source: (plan: IngestionPlan, phase: string) => any,
|
|
22
|
+
* maxPartitions?: number, maxPages?: number, maxRows?: number, maxBytes?: number }} options
|
|
23
|
+
*/
|
|
24
|
+
export declare function createIngestion(options: {
|
|
25
|
+
provider: {
|
|
26
|
+
pages: (input: any, context: any) => AsyncIterable<any>;
|
|
27
|
+
};
|
|
28
|
+
store: {
|
|
29
|
+
begin: Function;
|
|
30
|
+
stage: Function;
|
|
31
|
+
invalidate: Function;
|
|
32
|
+
publish: Function;
|
|
33
|
+
};
|
|
34
|
+
source: (plan: IngestionPlan, phase: string) => any;
|
|
35
|
+
maxPartitions?: number;
|
|
36
|
+
maxPages?: number;
|
|
37
|
+
maxRows?: number;
|
|
38
|
+
maxBytes?: number;
|
|
39
|
+
}): Readonly<{
|
|
40
|
+
/** @param {IngestionPlan} plan
|
|
41
|
+
* @param {{ executor: any, authority?: any, signal?: AbortSignal, deadline?: number }} resources */
|
|
42
|
+
run(plan: IngestionPlan, resources: {
|
|
43
|
+
executor: any;
|
|
44
|
+
authority?: any;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
deadline?: number;
|
|
47
|
+
}): Promise<any>;
|
|
48
|
+
}>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind one compiled workflow to mapped run/checkpoint persistence. Leases are
|
|
3
|
+
* the existing queue's capabilities; resources stay private and release only
|
|
4
|
+
* after tasks drain and final cancellation/failure observation is persisted.
|
|
5
|
+
* @param {any} document @param {{ store: any, schemaVersion: string, tasks?: any }} options
|
|
6
|
+
*/
|
|
7
|
+
export declare function createDomainRun(document: any, options: {
|
|
8
|
+
store: any;
|
|
9
|
+
schemaVersion: string;
|
|
10
|
+
tasks?: any;
|
|
11
|
+
}): Readonly<{
|
|
12
|
+
/** @param {string} id @param {any} input @param {{ lease: any, signal?: AbortSignal, release?: Function, event?: any, [key: string]: any }} resources */
|
|
13
|
+
run(id: string, input: any, resources: {
|
|
14
|
+
lease: any;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
release?: Function;
|
|
17
|
+
event?: any;
|
|
18
|
+
[key: string]: any;
|
|
19
|
+
}): Promise<import("./workflow.js").WorkflowResult>;
|
|
20
|
+
/** Cancellation authority is checked by the host's command. Navigation must
|
|
21
|
+
* detach observation, not call this method. Remote workers see durable intent
|
|
22
|
+
* at the next checkpoint; local workers abort and drain before this resolves.
|
|
23
|
+
* @param {string} id @param {number} revision @param {{ actor: string, reason: string }} evidence */
|
|
24
|
+
cancel(id: string, revision: number, evidence: {
|
|
25
|
+
actor: string;
|
|
26
|
+
reason: string;
|
|
27
|
+
}): Promise<any>;
|
|
28
|
+
}>;
|
package/dist/types/workflow.d.ts
CHANGED
|
@@ -73,6 +73,7 @@ export type WorkflowRunOptions = {
|
|
|
73
73
|
snapshot?: any;
|
|
74
74
|
expectedGeneration?: number;
|
|
75
75
|
signal?: AbortSignal;
|
|
76
|
+
resources?: unknown;
|
|
76
77
|
now?: number;
|
|
77
78
|
event?: {
|
|
78
79
|
type: string;
|
|
@@ -83,7 +84,7 @@ export type WorkflowRunOptions = {
|
|
|
83
84
|
/** @typedef {{ load: (runId: string) => WorkflowSnapshot | null | Promise<WorkflowSnapshot | null>,
|
|
84
85
|
* save: (runId: string, snapshot: WorkflowSnapshot, expectedGeneration: number) => boolean | Promise<boolean> }} WorkflowStore */
|
|
85
86
|
/** @typedef {{ runId: string, snapshot?: any, expectedGeneration?: number,
|
|
86
|
-
* signal?: AbortSignal, now?: number, event?: {type: string, payload?: any},
|
|
87
|
+
* signal?: AbortSignal, resources?: unknown, now?: number, event?: {type: string, payload?: any},
|
|
87
88
|
* onTrace?: (record: any) => void }} WorkflowRunOptions */
|
|
88
89
|
/** Compile once; each run owns its control and checkpoint records.
|
|
89
90
|
* Store save MUST atomically compare expectedGeneration (0 means absent).
|
package/docs/WORKFLOW-FORMAT.md
CHANGED
|
@@ -258,3 +258,95 @@ review/retry, plus general task/choice/loop/nesting/wait, failure/resume,
|
|
|
258
258
|
checkpoint reuse, generation races, changed identities, cancellation and
|
|
259
259
|
schema/compiler agreement. Streaming input, graphical editing, action-document
|
|
260
260
|
awaiting and domain orchestration projections are separate contracts.
|
|
261
|
+
|
|
262
|
+
## Complete provider ingestion
|
|
263
|
+
|
|
264
|
+
`createIngestion({provider,store,source,maxPartitions?,maxPages?,maxRows?,maxBytes?})`
|
|
265
|
+
compiles one workflow over injected capabilities. It imports no provider or
|
|
266
|
+
persistence package. `provider.pages(input,context)` supplies bounded pages;
|
|
267
|
+
`store.begin/stage/invalidate/publish` supplies atomic persistence. The public
|
|
268
|
+
implementations are `compileProvider` from `@jarenjs/contract/provider` and
|
|
269
|
+
`createDbIngestionStore` from `@jarenjs/linq/db`.
|
|
270
|
+
|
|
271
|
+
```js
|
|
272
|
+
import { createIngestion } from '@jarenjs/flow';
|
|
273
|
+
|
|
274
|
+
const ingestion = createIngestion({ provider, store,
|
|
275
|
+
source: async (plan, phase) => readSourceEvidence(plan, phase),
|
|
276
|
+
maxPartitions: 8, maxPages: 32, maxRows: 4096, maxBytes: 1048576,
|
|
277
|
+
});
|
|
278
|
+
const result = await ingestion.run({
|
|
279
|
+
source: 'inventory/test', version: 'source-snapshot-1', generation: 'pull-1',
|
|
280
|
+
partitions: ['north', 'south'], input: {}, policyRevision: 'policy-1',
|
|
281
|
+
consistency: 'snapshot',
|
|
282
|
+
}, { executor: providerRun, authority: providerRun, signal: providerRun.signal });
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
A plan has a source identity, source version, local generation, unique requested
|
|
286
|
+
partitions, JSON input and reconciliation policy revision. `consistency` is
|
|
287
|
+
`snapshot` (an upstream stable snapshot) or `revision` (an upstream monotonic
|
|
288
|
+
revision which changes with every relevant source mutation). The injected source
|
|
289
|
+
check returns `{version,consistency}` at start, after each page and before
|
|
290
|
+
publication. It must report actual upstream evidence, not echo local generation
|
|
291
|
+
numbers. Each page's version must match. A changed source invalidates staging;
|
|
292
|
+
a host may start a fresh generation after obtaining fresh source evidence.
|
|
293
|
+
|
|
294
|
+
The iterator resumes each unfinished partition from its committed continuation.
|
|
295
|
+
It waits for a page to commit before requesting another. No network request
|
|
296
|
+
holds a database transaction. Default aggregate limits are 64 partitions, 64
|
|
297
|
+
pages, 65536 rows and 16777216 bytes; counts include prior committed pages on
|
|
298
|
+
resume. The descriptor's page limits additionally bound an individual iterator.
|
|
299
|
+
An interrupted pull retains staging; failed/partial partitions never publish a
|
|
300
|
+
complete pointer. Pages reporting partial errors retain their exact raw wire
|
|
301
|
+
text and cannot complete a partition. Source-invalid pages within limits remain
|
|
302
|
+
inspectable but cannot certify completion.
|
|
303
|
+
|
|
304
|
+
Publication checks source and optional current authority again. For private
|
|
305
|
+
runs pass the same `withProviderRun` capability as executor and authority;
|
|
306
|
+
its publication gate suppresses callbacks after cancellation or revocation.
|
|
307
|
+
Returning `unchanged` requires matching source version, input, requested
|
|
308
|
+
partitions, consistency and policy revision, and produces zero fact writes or
|
|
309
|
+
revisions without requesting another page. A new source or policy revision
|
|
310
|
+
stages a new generation. The store owns transactional recovery; application
|
|
311
|
+
policy owns reconciliation, retention and downstream cutover.
|
|
312
|
+
|
|
313
|
+
DAG and workflow run options accept `resources` separately from JSON input.
|
|
314
|
+
Task handlers receive `(props, signal, resources)`; resources never participate
|
|
315
|
+
in checkpoint serialization, replay identity or trace output. A resource-bearing
|
|
316
|
+
run drains tasks on abort before settling so the caller can release its lease.
|
|
317
|
+
Tasks must return JSON results and keep resource handles out of their output.
|
|
318
|
+
|
|
319
|
+
## Domain run and external-effect adoption
|
|
320
|
+
|
|
321
|
+
`createExternalEffects({ store, executor, authorize, classify })` composes the
|
|
322
|
+
existing workflow engine with a mapped effect store and the public provider
|
|
323
|
+
executor. `run(operationId, { lease, signal? })` reads the reviewed plan,
|
|
324
|
+
reauthorizes resume and dispatch, and persists sending intent from the executor's
|
|
325
|
+
`beforeDispatch` hook. This runs after scheduler admission, immediately before
|
|
326
|
+
transport. A private one-attempt budget prevents executor retries from escaping
|
|
327
|
+
the durable per-leg budget. Only validated successful provider responses passed
|
|
328
|
+
through the host's `classify(response, plannedLeg)` become confirmed/rejected
|
|
329
|
+
public evidence. Timeout, abort, disconnect, invalid response or lost settlement
|
|
330
|
+
remain unresolved. Restart recovers stale sending intent without resending it.
|
|
331
|
+
Read-back and operator decisions use the store's explicit `reconcile` method.
|
|
332
|
+
Register `effects.handler` with the existing queue worker to pause unresolved or
|
|
333
|
+
refused operations in the queue's cancelled state. Explicit requeue/claim then
|
|
334
|
+
provides a fresh reconciliation fence; confirmed operations complete normally.
|
|
335
|
+
|
|
336
|
+
`createDomainRun(document, { store, schemaVersion, tasks })` compiles the existing
|
|
337
|
+
workflow once and binds its checkpoint CAS to application run records.
|
|
338
|
+
`run(id, input, { lease, signal?, event?, release?, ...resources })` preserves the
|
|
339
|
+
application run ID. `lease` may be a capability or a function returning the
|
|
340
|
+
worker's current renewed lease. Checkpoint provenance includes the canonical
|
|
341
|
+
workflow/schema identity and the engine's input/task-version identity.
|
|
342
|
+
|
|
343
|
+
`cancel(id, observedRevision, { actor, reason })` records explicit cancellation
|
|
344
|
+
intent, stops local task admission, signals and drains local workers, and waits
|
|
345
|
+
for their final observation and resource release. Remote workers see the durable
|
|
346
|
+
cancellation at the next checkpoint/admission boundary; a remote cancel request
|
|
347
|
+
may therefore return pending intent rather than completed cancellation. The
|
|
348
|
+
requesting contract must authorize the actor. A signal alone also requires
|
|
349
|
+
workers to drain; failed/cancelled observations use fixed public statuses without
|
|
350
|
+
raw exception messages. Attaching an observer is independent of starting/cancelling
|
|
351
|
+
a run. Reset is a separate, explicitly guarded store operation and cannot erase
|
|
352
|
+
business receipts or unresolved effects.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/flow",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.83.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"prepack": "npm run build:types"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@jarenjs/core": "^0.
|
|
53
|
-
"@jarenjs/json": "^0.
|
|
52
|
+
"@jarenjs/core": "^0.83.3",
|
|
53
|
+
"@jarenjs/json": "^0.83.3"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/dag.js
CHANGED
|
@@ -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, drainOnAbort?: boolean }) => Promise<any>} run -
|
|
74
|
+
* @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string, drainOnAbort?: boolean, resources?: unknown }) => Promise<any>} run -
|
|
75
75
|
* Execute the graph for one input (`undefined` reads as `null`).
|
|
76
76
|
*/
|
|
77
77
|
|
|
@@ -121,8 +121,8 @@ function normalizeTaskEntry(entry, name) {
|
|
|
121
121
|
* registry resolution — `run` only executes closures.
|
|
122
122
|
*
|
|
123
123
|
* @param {any} doc - the jaren-dag document
|
|
124
|
-
* @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
|
|
125
|
-
* | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
|
|
124
|
+
* @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal, resources?: any) => any)
|
|
125
|
+
* | { run: (props: { with: any, input: any }, signal: AbortSignal, resources?: any) => any, version?: string,
|
|
126
126
|
* taskVersions?: Record<string, string> }>,
|
|
127
127
|
* checkpoint?: DagCheckpointStore, revision?: string }} [options]
|
|
128
128
|
* @returns {CompiledDag}
|
|
@@ -413,7 +413,7 @@ export function compileDag(doc, options) {
|
|
|
413
413
|
throw new TypeError(
|
|
414
414
|
'run: a checkpointed dag needs a non-empty string "runId" to persist under');
|
|
415
415
|
}
|
|
416
|
-
return execute(input === undefined ? null : input, signal, onNode, runId, opts?.drainOnAbort === true);
|
|
416
|
+
return execute(input === undefined ? null : input, signal, onNode, runId, opts?.drainOnAbort === true || opts?.resources !== undefined, opts?.resources);
|
|
417
417
|
}
|
|
418
418
|
|
|
419
419
|
/**
|
|
@@ -422,8 +422,9 @@ export function compileDag(doc, options) {
|
|
|
422
422
|
* @param {((record: DagNodeRecord) => void)|undefined} onNode
|
|
423
423
|
* @param {string|undefined} runId
|
|
424
424
|
* @param {boolean} drainOnAbort
|
|
425
|
+
* @param {unknown} resources - host-private, never part of input/checkpoint identity
|
|
425
426
|
*/
|
|
426
|
-
async function execute(runInput, signal, onNode, runId, drainOnAbort) {
|
|
427
|
+
async function execute(runInput, signal, onNode, runId, drainOnAbort, resources) {
|
|
427
428
|
const controller = new AbortController();
|
|
428
429
|
/** @type {FlowRuntimeError|null} */
|
|
429
430
|
let failure = null;
|
|
@@ -566,7 +567,7 @@ export function compileDag(doc, options) {
|
|
|
566
567
|
with: node.with === null ? null : node.with(scope) ?? null,
|
|
567
568
|
input: scope,
|
|
568
569
|
};
|
|
569
|
-
value = (await node.handler(props, controller.signal)) ?? null;
|
|
570
|
+
value = (await node.handler(props, controller.signal, resources)) ?? null;
|
|
570
571
|
break;
|
|
571
572
|
}
|
|
572
573
|
default: value = null; break;
|
package/src/effects.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** External effects orchestrate existing workflows, provider admission and fenced storage. */
|
|
3
|
+
import { deepFreeze } from '@jarenjs/core/object';
|
|
4
|
+
import { compileWorkflow } from './workflow.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The provider executor is single-attempt for this composition. Its dispatch
|
|
8
|
+
* hook persists intent after scheduling/authority checks and before transport.
|
|
9
|
+
* Every ambiguous result stays unresolved; read-back/operator reconciliation is
|
|
10
|
+
* explicit. A new worker may inspect the record but cannot blindly resend it.
|
|
11
|
+
* @param {{ store: any, executor: any, authorize: Function, classify: Function }} options
|
|
12
|
+
*/
|
|
13
|
+
export function createExternalEffects(options) {
|
|
14
|
+
if (!options || typeof options.executor?.execute !== 'function' || typeof options.authorize !== 'function'
|
|
15
|
+
|| typeof options.classify !== 'function' || ['get', 'begin', 'settle', 'recover'].some((name) => typeof options.store?.[name] !== 'function'))
|
|
16
|
+
throw new TypeError('external effects need a fenced store, provider executor, authorization and evidence classifier');
|
|
17
|
+
const { store, executor, authorize, classify } = options;
|
|
18
|
+
const workflow = compileWorkflow({ $workflow: '0.2', revision: 'external-effects/1', initial: 'send', states: {
|
|
19
|
+
send: { work: { task: 'send', version: '1' }, then: 'done' }, done: { final: true },
|
|
20
|
+
} }, { tasks: { send: { version: '1', run: async ({ input }, signal, resources) => {
|
|
21
|
+
const lease = () => typeof resources.lease === 'function' ? resources.lease() : resources.lease;
|
|
22
|
+
let record = await store.get(input.id);
|
|
23
|
+
if (await authorize(deepFreeze(record.plan), 'resume', resources) !== true) return { state: 'refused', reason: 'unauthorized' };
|
|
24
|
+
record = (await store.recover(record.id, record.revision, lease())).record;
|
|
25
|
+
deepFreeze(record.plan);
|
|
26
|
+
for (const planned of record.plan.legs) {
|
|
27
|
+
const leg = record.legs.find((value) => value.id === planned.id);
|
|
28
|
+
if (leg.state === 'confirmed' || leg.state === 'rejected') continue;
|
|
29
|
+
if (signal.aborted) return { state: 'cancelled', revision: record.revision };
|
|
30
|
+
if (leg.state === 'unresolved') return { state: 'unresolved', revision: record.revision, leg: leg.id };
|
|
31
|
+
let started = false, used = 0;
|
|
32
|
+
const budget = { safety: planned.request.safety, get used() { return used; }, get remaining() { return 1 - used; }, take: () => used++ === 0 };
|
|
33
|
+
let response;
|
|
34
|
+
try {
|
|
35
|
+
response = await executor.execute(planned.request, { signal, budget, beforeDispatch: async () => {
|
|
36
|
+
if (started || await authorize(deepFreeze(record.plan), 'dispatch', resources) !== true) return false;
|
|
37
|
+
const intent = await store.begin(record.id, planned.id, record.revision, lease());
|
|
38
|
+
if (intent.state !== 'sending' || intent.writes !== 1) return false;
|
|
39
|
+
record = intent.record;
|
|
40
|
+
started = true;
|
|
41
|
+
return true;
|
|
42
|
+
} });
|
|
43
|
+
}
|
|
44
|
+
catch { response = { state: 'unresolved' }; }
|
|
45
|
+
if (!started) return { state: 'refused', reason: 'not-admitted', revision: record.revision };
|
|
46
|
+
let outcome = { state: 'unresolved', evidence: { reason: 'unconfirmed' } };
|
|
47
|
+
if (!signal.aborted && response.state === 'ok') {
|
|
48
|
+
try {
|
|
49
|
+
const observed = await classify(response, planned);
|
|
50
|
+
if (observed && ['confirmed', 'rejected'].includes(observed.state) && Object.hasOwn(observed, 'evidence')) outcome = observed;
|
|
51
|
+
}
|
|
52
|
+
catch { /* malformed or unvalidated remote evidence remains unresolved */ }
|
|
53
|
+
}
|
|
54
|
+
record = (await store.settle(record.id, planned.id, record.revision, lease(), outcome)).record;
|
|
55
|
+
if (outcome.state === 'unresolved') return { state: 'unresolved', revision: record.revision, leg: planned.id };
|
|
56
|
+
}
|
|
57
|
+
return { state: 'complete', revision: record.revision, legs: record.legs.map(({ id, state }) => ({ id, state })) };
|
|
58
|
+
} } } });
|
|
59
|
+
/** @param {string} id @param {{ lease: any, signal?: AbortSignal }} resources */
|
|
60
|
+
async function run(id, resources) {
|
|
61
|
+
const result = await workflow.run({ id }, { runId: id, resources, signal: resources.signal });
|
|
62
|
+
return result.result;
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
run,
|
|
66
|
+
/** Existing queue worker binding. Unresolved/refused operations pause in the
|
|
67
|
+
* queue's cancelled state, which can be explicitly requeued for reconciliation.
|
|
68
|
+
* They must not become completed jobs whose identity cannot be claimed again.
|
|
69
|
+
* @param {{ operationId: string }} payload @param {any} context */
|
|
70
|
+
async handler(payload, context) {
|
|
71
|
+
if (typeof context?.pause !== 'function') throw new TypeError('external handler needs a job worker pause capability');
|
|
72
|
+
const result = await run(payload.operationId, context);
|
|
73
|
+
if (result.state !== 'complete') await context.pause();
|
|
74
|
+
return result;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { fsmToApp, fsmStateSchema } from './app.js';
|
|
|
11
11
|
export { compileDag } from './dag.js';
|
|
12
12
|
export { compileStatechart, createStatechartSession } from './statechart.js';
|
|
13
13
|
export { lowerWorkflow, compileWorkflow } from './workflow.js';
|
|
14
|
+
export { createIngestion } from './ingest.js';
|
|
14
15
|
export { snapshotFsm, resumeFsmSession, createDurableFsmSession } from './persist.js';
|
|
15
16
|
export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
|
|
16
17
|
|
|
@@ -22,3 +23,5 @@ export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
|
|
|
22
23
|
/** @typedef {import('./workflow.js').WorkflowStore} WorkflowStore */
|
|
23
24
|
/** @typedef {import('./workflow.js').WorkflowResult} WorkflowResult */
|
|
24
25
|
/** @typedef {import('./workflow.js').CompiledWorkflow} CompiledWorkflow */
|
|
26
|
+
export { createExternalEffects } from './effects.js';
|
|
27
|
+
export { createDomainRun } from './runs.js';
|
package/src/ingest.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Resumable complete ingestion over injected provider and transaction stores. */
|
|
3
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
4
|
+
import { compileWorkflow } from './workflow.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{ source: string, version: string, generation: string,
|
|
8
|
+
* partitions: string[], input: any, policyRevision: string,
|
|
9
|
+
* consistency: 'snapshot' | 'revision' }} IngestionPlan
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compose the existing workflow engine with bounded provider pages and atomic
|
|
14
|
+
* staging. source(plan, phase) proves either a stable source snapshot or a
|
|
15
|
+
* monotonic source revision. Local generation numbers are never that proof.
|
|
16
|
+
* @param {{ provider: { pages: (input: any, context: any) => AsyncIterable<any> },
|
|
17
|
+
* store: { begin: Function, stage: Function, invalidate: Function, publish: Function },
|
|
18
|
+
* source: (plan: IngestionPlan, phase: string) => any,
|
|
19
|
+
* maxPartitions?: number, maxPages?: number, maxRows?: number, maxBytes?: number }} options
|
|
20
|
+
*/
|
|
21
|
+
export function createIngestion(options) {
|
|
22
|
+
if (!options || typeof options.provider?.pages !== 'function' || typeof options.source !== 'function'
|
|
23
|
+
|| ['begin', 'stage', 'invalidate', 'publish'].some((name) => typeof options.store?.[name] !== 'function'))
|
|
24
|
+
throw new TypeError('ingestion needs provider pages, source evidence and an atomic staging store');
|
|
25
|
+
const { provider, store, source, maxPartitions = 64, maxPages = 64, maxRows = 65536, maxBytes = 16777216 } = options;
|
|
26
|
+
for (const value of [maxPartitions, maxPages, maxRows, maxBytes]) if (!Number.isSafeInteger(value) || value < 1) throw new TypeError('ingestion limits must be positive finite integers');
|
|
27
|
+
const workflow = compileWorkflow({ $workflow: '0.2', revision: 'ingestion/1', initial: 'pull', states: {
|
|
28
|
+
pull: { work: { task: 'ingest', version: '1' }, then: 'done' }, done: { final: true },
|
|
29
|
+
} }, { tasks: { ingest: { version: '1', run: async ({ input: plan }, signal, resources) => {
|
|
30
|
+
const incomplete = (reason) => ({ state: 'incomplete', reason, changes: 0, writes: 0, revisions: 0 });
|
|
31
|
+
const proof = async (phase) => {
|
|
32
|
+
const evidence = await source(plan, phase);
|
|
33
|
+
return !signal.aborted && evidence?.version === plan.version && evidence.consistency === plan.consistency
|
|
34
|
+
? { version: evidence.version, consistency: evidence.consistency } : null;
|
|
35
|
+
};
|
|
36
|
+
let evidence = await proof('start');
|
|
37
|
+
if (!evidence) return incomplete('source-changed');
|
|
38
|
+
if (resources.authority && !await resources.authority.check()) return incomplete('authority-changed');
|
|
39
|
+
const begun = await store.begin(plan);
|
|
40
|
+
if (begun.state !== 'staging') return begun;
|
|
41
|
+
let checkpoint = begun.checkpoint;
|
|
42
|
+
for (const partition of plan.partitions) {
|
|
43
|
+
const saved = checkpoint.partitions.find((part) => part.id === partition);
|
|
44
|
+
if (saved.complete) continue;
|
|
45
|
+
let complete = false;
|
|
46
|
+
const pages = provider.pages(plan.input, { executor: resources.executor, signal, partition,
|
|
47
|
+
sourceVersion: plan.version, cursor: saved.cursor, deadline: resources.deadline });
|
|
48
|
+
try {
|
|
49
|
+
for await (const page of pages) {
|
|
50
|
+
if (signal.aborted) return incomplete('cancelled');
|
|
51
|
+
if (page.state !== 'page') {
|
|
52
|
+
if (page.state !== 'complete') return incomplete(page.reason);
|
|
53
|
+
complete = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Network has settled before entering stage's transaction. The exact
|
|
57
|
+
// raw wire text remains beside compiled observations in that commit.
|
|
58
|
+
evidence = await proof('page');
|
|
59
|
+
if (!evidence || page.version !== plan.version) {
|
|
60
|
+
if (checkpoint.pages + 1 <= maxPages && checkpoint.rows + page.rows.length <= maxRows && checkpoint.bytes + page.bytes <= maxBytes)
|
|
61
|
+
await store.stage(plan, partition, { ...page, complete: false, reason: 'source-changed' });
|
|
62
|
+
await store.invalidate(plan, 'source-changed');
|
|
63
|
+
return incomplete('source-changed');
|
|
64
|
+
}
|
|
65
|
+
if (checkpoint.pages + 1 > maxPages || checkpoint.rows + page.rows.length > maxRows || checkpoint.bytes + page.bytes > maxBytes)
|
|
66
|
+
return incomplete('ingestion-limit');
|
|
67
|
+
if (resources.authority && !await resources.authority.check()) return incomplete('authority-changed');
|
|
68
|
+
const staged = await store.stage(plan, partition, page);
|
|
69
|
+
if (staged.state !== 'staged') return incomplete(staged.reason);
|
|
70
|
+
checkpoint = staged.checkpoint;
|
|
71
|
+
if (page.reason !== null) return incomplete(page.reason);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
if (signal.aborted) return incomplete('cancelled');
|
|
76
|
+
return incomplete('interrupted');
|
|
77
|
+
}
|
|
78
|
+
if (!complete) return incomplete('missing-completion');
|
|
79
|
+
}
|
|
80
|
+
evidence = await proof('publish');
|
|
81
|
+
if (!evidence) { await store.invalidate(plan, 'source-changed'); return incomplete('source-changed'); }
|
|
82
|
+
if (signal.aborted) return incomplete('cancelled');
|
|
83
|
+
return resources.authority
|
|
84
|
+
? resources.authority.publish(evidence, (proof) => store.publish(plan, proof, { signal }))
|
|
85
|
+
: store.publish(plan, evidence, { signal });
|
|
86
|
+
} } } });
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
/** @param {IngestionPlan} plan
|
|
89
|
+
* @param {{ executor: any, authority?: any, signal?: AbortSignal, deadline?: number }} resources */
|
|
90
|
+
async run(plan, resources) {
|
|
91
|
+
if (!plan || ['source', 'version', 'generation', 'policyRevision'].some((name) => typeof plan[name] !== 'string' || !plan[name])
|
|
92
|
+
|| !['snapshot', 'revision'].includes(plan.consistency) || !Array.isArray(plan.partitions)
|
|
93
|
+
|| !plan.partitions.length || plan.partitions.length > maxPartitions
|
|
94
|
+
|| plan.partitions.some((partition) => typeof partition !== 'string' || !partition)
|
|
95
|
+
|| new Set(plan.partitions).size !== plan.partitions.length || !Object.hasOwn(plan, 'input')
|
|
96
|
+
|| Object.keys(plan).some((name) => !['source', 'version', 'generation', 'partitions', 'input', 'policyRevision', 'consistency'].includes(name)))
|
|
97
|
+
throw new TypeError('ingestion plan needs source/version/generation, unique bounded partitions, input, policyRevision and consistency');
|
|
98
|
+
const input = JSON.parse(canonicalizeJson(plan));
|
|
99
|
+
const result = await workflow.run(input, { runId: canonicalizeJson([plan.source, plan.generation]), resources,
|
|
100
|
+
signal: resources.signal });
|
|
101
|
+
return result.result;
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
package/src/runs.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Adopt application run records without adding an orchestration engine. */
|
|
3
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
4
|
+
import { compileWorkflow } from './workflow.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bind one compiled workflow to mapped run/checkpoint persistence. Leases are
|
|
8
|
+
* the existing queue's capabilities; resources stay private and release only
|
|
9
|
+
* after tasks drain and final cancellation/failure observation is persisted.
|
|
10
|
+
* @param {any} document @param {{ store: any, schemaVersion: string, tasks?: any }} options
|
|
11
|
+
*/
|
|
12
|
+
export function createDomainRun(document, options) {
|
|
13
|
+
if (!options?.store || typeof options.schemaVersion !== 'string' || !options.schemaVersion
|
|
14
|
+
|| ['attach', 'load', 'save', 'get', 'finish', 'requestCancel'].some((name) => typeof options.store[name] !== 'function'))
|
|
15
|
+
throw new TypeError('domain run needs a mapped run store and schemaVersion');
|
|
16
|
+
const { store, schemaVersion } = options;
|
|
17
|
+
const workflowIdentity = canonicalizeJson(document);
|
|
18
|
+
const active = new Map();
|
|
19
|
+
const bound = (id) => {
|
|
20
|
+
const attempt = active.get(id);
|
|
21
|
+
if (!attempt) throw new TypeError('run has no active attempt');
|
|
22
|
+
return attempt;
|
|
23
|
+
};
|
|
24
|
+
const workflow = compileWorkflow(document, { tasks: options.tasks, store: {
|
|
25
|
+
load: (id) => { const attempt = bound(id); return store.load(id, attempt.identity, attempt.lease()); },
|
|
26
|
+
save: (id, snapshot, expected) => store.save(id, snapshot, expected, bound(id).lease()),
|
|
27
|
+
} });
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
/** @param {string} id @param {any} input @param {{ lease: any, signal?: AbortSignal, release?: Function, event?: any, [key: string]: any }} resources */
|
|
30
|
+
async run(id, input, resources) {
|
|
31
|
+
if (active.has(id)) throw new TypeError('domain run is already active in this runner');
|
|
32
|
+
const lease = () => typeof resources.lease === 'function' ? resources.lease() : resources.lease;
|
|
33
|
+
const identity = { id, jobId: lease()?.jobId, workflow: workflowIdentity, schemaVersion };
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const signal = resources.signal ? AbortSignal.any([controller.signal, resources.signal]) : controller.signal;
|
|
36
|
+
const drained = Promise.withResolvers();
|
|
37
|
+
const attempt = { identity, lease, controller, drained: drained.promise };
|
|
38
|
+
active.set(id, attempt);
|
|
39
|
+
let attached = false;
|
|
40
|
+
try {
|
|
41
|
+
await store.attach(identity, lease()); attached = true;
|
|
42
|
+
return await workflow.run(input, { runId: id, signal, resources, event: resources.event });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (attached) {
|
|
46
|
+
const record = await store.get(id);
|
|
47
|
+
await store.finish(id, record.cancelRequested || signal.aborted ? 'cancelled' : 'failed', lease());
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
active.delete(id);
|
|
53
|
+
try { await resources.release?.(); }
|
|
54
|
+
finally { drained.resolve(undefined); }
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
/** Cancellation authority is checked by the host's command. Navigation must
|
|
58
|
+
* detach observation, not call this method. Remote workers see durable intent
|
|
59
|
+
* at the next checkpoint; local workers abort and drain before this resolves.
|
|
60
|
+
* @param {string} id @param {number} revision @param {{ actor: string, reason: string }} evidence */
|
|
61
|
+
async cancel(id, revision, evidence) {
|
|
62
|
+
await store.requestCancel(id, revision, evidence);
|
|
63
|
+
const attempt = active.get(id);
|
|
64
|
+
if (attempt) { attempt.controller.abort(); await attempt.drained; }
|
|
65
|
+
return store.get(id);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
package/src/workflow.js
CHANGED
|
@@ -173,7 +173,7 @@ export function lowerWorkflow(document) {
|
|
|
173
173
|
/** @typedef {{ load: (runId: string) => WorkflowSnapshot | null | Promise<WorkflowSnapshot | null>,
|
|
174
174
|
* save: (runId: string, snapshot: WorkflowSnapshot, expectedGeneration: number) => boolean | Promise<boolean> }} WorkflowStore */
|
|
175
175
|
/** @typedef {{ runId: string, snapshot?: any, expectedGeneration?: number,
|
|
176
|
-
* signal?: AbortSignal, now?: number, event?: {type: string, payload?: any},
|
|
176
|
+
* signal?: AbortSignal, resources?: unknown, now?: number, event?: {type: string, payload?: any},
|
|
177
177
|
* onTrace?: (record: any) => void }} WorkflowRunOptions */
|
|
178
178
|
/** Compile once; each run owns its control and checkpoint records.
|
|
179
179
|
* Store save MUST atomically compare expectedGeneration (0 means absent).
|
|
@@ -407,9 +407,14 @@ export function compileWorkflow(document, options = {}) {
|
|
|
407
407
|
let result;
|
|
408
408
|
let error = null;
|
|
409
409
|
try {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
410
|
+
if (Object.hasOwn(pending, 'result')) result = pending.result;
|
|
411
|
+
else {
|
|
412
|
+
const task = dags.get(id).run(clone(pending.input), { runId: key, signal: controller.signal,
|
|
413
|
+
resources: opts.resources,
|
|
414
|
+
onNode: (record) => observe({ type: 'node', state: id, activation: pending.visit, ...record }) });
|
|
415
|
+
// A private resource lease outlives every worker that received it.
|
|
416
|
+
result = opts.resources === undefined ? await wait(task) : await task;
|
|
417
|
+
}
|
|
413
418
|
}
|
|
414
419
|
catch (err) {
|
|
415
420
|
if (spec.catch === undefined || err?.code !== 'JF2006') throw err;
|