@nanobpm/bojtos-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/session.d.ts +85 -0
- package/dist/session.js +80 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.js +6 -0
- package/dist/worker.d.ts +81 -0
- package/dist/worker.js +92 -0
- package/package.json +43 -0
- package/src/index.ts +30 -0
- package/src/session.ts +191 -0
- package/src/types.ts +95 -0
- package/src/worker.ts +164 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @nanobpm/bojtos-kit
|
|
2
|
+
|
|
3
|
+
Framework-agnostic core of the **Bojtos** in-browser BPMN demo framework
|
|
4
|
+
([ADR 0043](../docs/adr/0043-bojtos-demo-framework.md)).
|
|
5
|
+
|
|
6
|
+
It wraps [`@nanobpm/engine-wasm`](../engine-wasm) as a single scenario runner —
|
|
7
|
+
the one runner the whole framework (and the console test-run panel) drives, so
|
|
8
|
+
there is no second, drift-prone engine harness — and re-exports the engine's
|
|
9
|
+
snapshot/event contract types.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createBojtosSession } from "@nanobpm/bojtos-kit";
|
|
13
|
+
|
|
14
|
+
const session = await createBojtosSession(); // loads the wasm engine once
|
|
15
|
+
const { processIds } = session.deploy(bpmnXml);
|
|
16
|
+
let snapshot = session.createInstance(processIds[0], "{}");
|
|
17
|
+
// a service task is now waiting as a job:
|
|
18
|
+
snapshot = session.completeJob(snapshot.jobs[0].key, JSON.stringify({ ok: true }));
|
|
19
|
+
const trace = session.events(); // WasmEvent[] for a step/trace view
|
|
20
|
+
session.free();
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Every command returns the post-run `Snapshot`: `activeElementIds` /
|
|
24
|
+
`incidentElementIds` drive the token/incident highlight, and
|
|
25
|
+
`instances[].variables` is the live payload that mutates as workers complete.
|
|
26
|
+
|
|
27
|
+
For React, use [`@nanobpm/bojtos-react`](../bojtos-react), which owns the session
|
|
28
|
+
lifecycle and reactive state on top of this kit.
|
|
29
|
+
|
|
30
|
+
## Build
|
|
31
|
+
|
|
32
|
+
`dist/` (the tsc-emitted JS + `.d.ts`) is committed so `file:` consumers and CI
|
|
33
|
+
need no build-on-install step. Regenerate with `npm run build`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ensureWasm, createBojtosSession, type BojtosSession, type WasmSource, } from "./session.js";
|
|
2
|
+
export { dispatchWorkers, dispatchRound, JobFailure, type JobHandler, type JobResult, type DispatchOptions, type DispatchResult, type RoundResult, } from "./worker.js";
|
|
3
|
+
export type { Snapshot, InstanceDto, JobDto, ActivatedJob, IncidentDto, TimerDto, ActiveEl, WasmEvent, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// @nanobpm/bojtos-kit — the framework-agnostic core of the Bojtos demo
|
|
2
|
+
// framework (ADR 0043). Wraps the in-browser wasm engine as a single scenario
|
|
3
|
+
// runner and re-exports the engine's snapshot/event contract types.
|
|
4
|
+
export { ensureWasm, createBojtosSession, } from "./session.js";
|
|
5
|
+
export { dispatchWorkers, dispatchRound, JobFailure, } from "./worker.js";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type InitInput } from "@nanobpm/engine-wasm";
|
|
2
|
+
import type { ActivatedJob, Snapshot, WasmEvent } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The source of the engine wasm binary. Under a bundler that understands
|
|
5
|
+
* `new URL(..., import.meta.url)` (e.g. Vite) the default loader needs no
|
|
6
|
+
* argument; pass an explicit `URL` / `Response` / bytes / `WebAssembly.Module`
|
|
7
|
+
* when the environment can't resolve it that way (Node, Jest, or the external-
|
|
8
|
+
* `.wasm` "wasmUrl" mode — ADR 0043 §3).
|
|
9
|
+
*/
|
|
10
|
+
export type WasmSource = InitInput;
|
|
11
|
+
/**
|
|
12
|
+
* Initialise the wasm engine module (idempotent; safe to call repeatedly). The
|
|
13
|
+
* first successful call wins: a `source` passed to a later call is ignored once
|
|
14
|
+
* the module is already loading or loaded. Pass a `source` in environments where
|
|
15
|
+
* the default `import.meta.url` fetch can't resolve the binary
|
|
16
|
+
* (Node/Jest/webpack).
|
|
17
|
+
*
|
|
18
|
+
* If a load *fails*, the cached promise is cleared so a later call — e.g. one
|
|
19
|
+
* that supplies a working `WasmSource` after the default loader couldn't resolve
|
|
20
|
+
* the binary — can retry rather than being stuck on the first rejection.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ensureWasm(source?: WasmSource): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* A headless handle to one in-browser engine instance: deploy a diagram, start
|
|
25
|
+
* instances, complete/fail jobs, advance the virtual clock, and read the event
|
|
26
|
+
* log. Every command returns the post-run {@link Snapshot}. This is the single
|
|
27
|
+
* scenario runner the Bojtos framework and the console both drive (ADR 0043 §8);
|
|
28
|
+
* framework bindings (`@nanobpm/bojtos-react`) own the reactive state on top.
|
|
29
|
+
*/
|
|
30
|
+
export interface BojtosSession {
|
|
31
|
+
/**
|
|
32
|
+
* Parse and deploy a BPMN resource. Returns the deployable process ids.
|
|
33
|
+
* Throws a JS error carrying the parse/deploy failure message.
|
|
34
|
+
*/
|
|
35
|
+
deploy(xml: string): {
|
|
36
|
+
processIds: string[];
|
|
37
|
+
};
|
|
38
|
+
/** Start an instance of `processId`, seeding it with `variablesJson`. */
|
|
39
|
+
createInstance(processId: string, variablesJson: string): Snapshot;
|
|
40
|
+
/**
|
|
41
|
+
* Activate up to `maxJobs` `Created` jobs of `jobType`, locking them to
|
|
42
|
+
* `worker` until `now + timeoutMs`. Returns the activated jobs (each carrying
|
|
43
|
+
* the instance's current variables) for a dispatch loop to hand to worker
|
|
44
|
+
* handlers. A job that is already activated is not re-returned.
|
|
45
|
+
*/
|
|
46
|
+
activateJobs(jobType: string, maxJobs: number, timeoutMs: number, worker: string): ActivatedJob[];
|
|
47
|
+
/** Complete a waiting job, merging `variablesJson` into the instance. */
|
|
48
|
+
completeJob(jobKey: string, variablesJson: string): Snapshot;
|
|
49
|
+
/** Fail a waiting job; with no retries left this raises an incident. */
|
|
50
|
+
failJob(jobKey: string, retries: number, message: string): Snapshot;
|
|
51
|
+
/**
|
|
52
|
+
* Correlate a message to any instance waiting on it: publishes `messageName`
|
|
53
|
+
* with `correlationKey` (the value the waiting subscription's `correlationKey`
|
|
54
|
+
* expression resolved to) and merges `variablesJson` into each correlated
|
|
55
|
+
* instance. Unblocks a message intermediate-catch / receive task without an
|
|
56
|
+
* external broker — the in-browser equivalent of an app publishing a message.
|
|
57
|
+
*/
|
|
58
|
+
correlateMessage(messageName: string, correlationKey: string, variablesJson: string): Snapshot;
|
|
59
|
+
/** Advance the virtual clock by `byMs`, firing due timers and lapsed locks. */
|
|
60
|
+
advanceTime(byMs: number): Snapshot;
|
|
61
|
+
/**
|
|
62
|
+
* Discard all engine state (definitions, instances, jobs, timers, event log
|
|
63
|
+
* and clock), returning the underlying engine to its pristine state. The
|
|
64
|
+
* caller redeploys afterwards to begin a clean run — this is what lets a
|
|
65
|
+
* re-run start from zero completed instances instead of accumulating across
|
|
66
|
+
* runs.
|
|
67
|
+
*/
|
|
68
|
+
reset(): void;
|
|
69
|
+
/** The full ordered event log emitted so far. */
|
|
70
|
+
events(): WasmEvent[];
|
|
71
|
+
/** The current simulation state. */
|
|
72
|
+
snapshot(): Snapshot;
|
|
73
|
+
/** Release the underlying wasm engine. */
|
|
74
|
+
free(): void;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a fresh headless engine session. Ensures the wasm module is loaded
|
|
78
|
+
* (once per page), then constructs a new {@link TestEngine}. The virtual clock
|
|
79
|
+
* starts at 0; deploy a diagram before starting instances. Pass a `wasm` source
|
|
80
|
+
* in environments where the default `import.meta.url` loader can't resolve the
|
|
81
|
+
* binary (Node/Jest, or the external-`.wasm` mode — ADR 0043 §3).
|
|
82
|
+
*/
|
|
83
|
+
export declare function createBojtosSession(opts?: {
|
|
84
|
+
wasm?: WasmSource;
|
|
85
|
+
}): Promise<BojtosSession>;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import init, { TestEngine } from "@nanobpm/engine-wasm";
|
|
2
|
+
// Lazily initialise the wasm module exactly once per page, no matter how many
|
|
3
|
+
// sessions are created. Mirrors the console's original `ensureWasm`.
|
|
4
|
+
let wasmReady = null;
|
|
5
|
+
/**
|
|
6
|
+
* Initialise the wasm engine module (idempotent; safe to call repeatedly). The
|
|
7
|
+
* first successful call wins: a `source` passed to a later call is ignored once
|
|
8
|
+
* the module is already loading or loaded. Pass a `source` in environments where
|
|
9
|
+
* the default `import.meta.url` fetch can't resolve the binary
|
|
10
|
+
* (Node/Jest/webpack).
|
|
11
|
+
*
|
|
12
|
+
* If a load *fails*, the cached promise is cleared so a later call — e.g. one
|
|
13
|
+
* that supplies a working `WasmSource` after the default loader couldn't resolve
|
|
14
|
+
* the binary — can retry rather than being stuck on the first rejection.
|
|
15
|
+
*/
|
|
16
|
+
export function ensureWasm(source) {
|
|
17
|
+
if (!wasmReady) {
|
|
18
|
+
wasmReady = init(source === undefined ? undefined : { module_or_path: source })
|
|
19
|
+
.then(() => undefined)
|
|
20
|
+
.catch((e) => {
|
|
21
|
+
wasmReady = null;
|
|
22
|
+
throw e;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return wasmReady;
|
|
26
|
+
}
|
|
27
|
+
function parseSnapshot(json) {
|
|
28
|
+
// The wasm engine is the schema authority; its JSON is the contract boundary.
|
|
29
|
+
return JSON.parse(json);
|
|
30
|
+
}
|
|
31
|
+
class WasmBojtosSession {
|
|
32
|
+
engine;
|
|
33
|
+
constructor(engine) {
|
|
34
|
+
this.engine = engine;
|
|
35
|
+
}
|
|
36
|
+
deploy(xml) {
|
|
37
|
+
return JSON.parse(this.engine.deploy(xml));
|
|
38
|
+
}
|
|
39
|
+
createInstance(processId, variablesJson) {
|
|
40
|
+
return parseSnapshot(this.engine.createInstance(processId, variablesJson || "{}"));
|
|
41
|
+
}
|
|
42
|
+
activateJobs(jobType, maxJobs, timeoutMs, worker) {
|
|
43
|
+
return JSON.parse(this.engine.activateJobs(jobType, maxJobs, timeoutMs, worker));
|
|
44
|
+
}
|
|
45
|
+
completeJob(jobKey, variablesJson) {
|
|
46
|
+
return parseSnapshot(this.engine.completeJob(jobKey, variablesJson || "{}"));
|
|
47
|
+
}
|
|
48
|
+
failJob(jobKey, retries, message) {
|
|
49
|
+
return parseSnapshot(this.engine.failJob(jobKey, retries, message));
|
|
50
|
+
}
|
|
51
|
+
correlateMessage(messageName, correlationKey, variablesJson) {
|
|
52
|
+
return parseSnapshot(this.engine.correlateMessage(messageName, correlationKey, variablesJson || "{}"));
|
|
53
|
+
}
|
|
54
|
+
advanceTime(byMs) {
|
|
55
|
+
return parseSnapshot(this.engine.advanceTime(byMs));
|
|
56
|
+
}
|
|
57
|
+
reset() {
|
|
58
|
+
this.engine.reset();
|
|
59
|
+
}
|
|
60
|
+
events() {
|
|
61
|
+
return JSON.parse(this.engine.events());
|
|
62
|
+
}
|
|
63
|
+
snapshot() {
|
|
64
|
+
return parseSnapshot(this.engine.snapshot());
|
|
65
|
+
}
|
|
66
|
+
free() {
|
|
67
|
+
this.engine.free();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Create a fresh headless engine session. Ensures the wasm module is loaded
|
|
72
|
+
* (once per page), then constructs a new {@link TestEngine}. The virtual clock
|
|
73
|
+
* starts at 0; deploy a diagram before starting instances. Pass a `wasm` source
|
|
74
|
+
* in environments where the default `import.meta.url` loader can't resolve the
|
|
75
|
+
* binary (Node/Jest, or the external-`.wasm` mode — ADR 0043 §3).
|
|
76
|
+
*/
|
|
77
|
+
export async function createBojtosSession(opts) {
|
|
78
|
+
await ensureWasm(opts?.wasm);
|
|
79
|
+
return new WasmBojtosSession(new TestEngine());
|
|
80
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** One active element token within an instance. */
|
|
2
|
+
export interface ActiveEl {
|
|
3
|
+
key: string;
|
|
4
|
+
elementId: string;
|
|
5
|
+
}
|
|
6
|
+
/** A process instance's live state. */
|
|
7
|
+
export interface InstanceDto {
|
|
8
|
+
key: string;
|
|
9
|
+
processId: string;
|
|
10
|
+
state: string;
|
|
11
|
+
completed: boolean;
|
|
12
|
+
activeElements: ActiveEl[];
|
|
13
|
+
variables: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
/** A job waiting for a worker. */
|
|
16
|
+
export interface JobDto {
|
|
17
|
+
key: string;
|
|
18
|
+
instanceKey: string;
|
|
19
|
+
elementId: string;
|
|
20
|
+
jobType: string;
|
|
21
|
+
state: string;
|
|
22
|
+
retries: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A job locked to a worker by {@link BojtosSession.activateJobs}, ready to hand
|
|
26
|
+
* to a {@link JobHandler}. Carries the instance's current `variables` so a
|
|
27
|
+
* handler can compute its output from the live payload. `key` is what
|
|
28
|
+
* `completeJob` / `failJob` take.
|
|
29
|
+
*/
|
|
30
|
+
export interface ActivatedJob {
|
|
31
|
+
key: string;
|
|
32
|
+
type: string;
|
|
33
|
+
instanceKey: string;
|
|
34
|
+
elementId: string;
|
|
35
|
+
retries: number;
|
|
36
|
+
variables: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
/** An incident raised on an element. */
|
|
39
|
+
export interface IncidentDto {
|
|
40
|
+
key: string;
|
|
41
|
+
instanceKey: string;
|
|
42
|
+
elementId: string;
|
|
43
|
+
kind: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
}
|
|
46
|
+
/** A pending timer. */
|
|
47
|
+
export interface TimerDto {
|
|
48
|
+
key: string;
|
|
49
|
+
instanceKey: string;
|
|
50
|
+
elementId: string;
|
|
51
|
+
dueAt: number;
|
|
52
|
+
dueInMs: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The full simulation state returned by every engine command. `activeElementIds`
|
|
56
|
+
* / `incidentElementIds` drive the token/incident highlight (the visual
|
|
57
|
+
* contract, ADR 0043 §4); `instances[].variables` is the live payload.
|
|
58
|
+
*/
|
|
59
|
+
export interface Snapshot {
|
|
60
|
+
now: number;
|
|
61
|
+
eventCount: number;
|
|
62
|
+
/** Present on a `createInstance` snapshot: the new instance key. */
|
|
63
|
+
created?: string;
|
|
64
|
+
totalInstances: number;
|
|
65
|
+
completedInstances: number;
|
|
66
|
+
instances: InstanceDto[];
|
|
67
|
+
jobs: JobDto[];
|
|
68
|
+
incidents: IncidentDto[];
|
|
69
|
+
timers: TimerDto[];
|
|
70
|
+
activeElementIds: string[];
|
|
71
|
+
incidentElementIds: string[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A flattened wasm engine event: `{ seq, now, type, ...snake_case fields }`.
|
|
75
|
+
* Produced by `TestEngine.events()` and folded into a trace view by consumers.
|
|
76
|
+
*/
|
|
77
|
+
export interface WasmEvent {
|
|
78
|
+
seq: number;
|
|
79
|
+
now: number;
|
|
80
|
+
type: string;
|
|
81
|
+
[k: string]: unknown;
|
|
82
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The shapes the in-browser engine (`@nanobpm/engine-wasm`) emits from its
|
|
2
|
+
// JSON string surface — `deploy` / `createInstance` / `completeJob` / `failJob`
|
|
3
|
+
// / `advanceTime` / `snapshot` return a `Snapshot`, and `events()` returns a
|
|
4
|
+
// `WasmEvent[]`. These describe the engine's public contract, so they live in
|
|
5
|
+
// the framework-agnostic kit and are re-exported by the React binding.
|
|
6
|
+
export {};
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { BojtosSession } from "./session.js";
|
|
2
|
+
import type { ActivatedJob, Snapshot } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The variables a handler merges into its instance on completion. Return an
|
|
5
|
+
* object to merge it, or `void`/`undefined` to complete with no new variables.
|
|
6
|
+
* To fail a job instead, throw — a plain `Error` fails it with `retries - 1`
|
|
7
|
+
* (an incident once retries reach 0); throw a {@link JobFailure} to set the
|
|
8
|
+
* remaining retries explicitly.
|
|
9
|
+
*/
|
|
10
|
+
export type JobResult = Record<string, unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* A worker for one job type: given an {@link ActivatedJob} (carrying the
|
|
13
|
+
* instance's current variables), compute the output variables to merge on
|
|
14
|
+
* completion. May be async. Throw to fail the job.
|
|
15
|
+
*/
|
|
16
|
+
export type JobHandler = (job: ActivatedJob) => JobResult | void | Promise<JobResult | void>;
|
|
17
|
+
/**
|
|
18
|
+
* Throw from a {@link JobHandler} to fail a job with an explicit remaining
|
|
19
|
+
* `retries` count (default is `job.retries - 1`). With `retries: 0` the engine
|
|
20
|
+
* raises an incident, which surfaces in the snapshot's `incidentElementIds` —
|
|
21
|
+
* handy for demoing the failure path deterministically.
|
|
22
|
+
*/
|
|
23
|
+
export declare class JobFailure extends Error {
|
|
24
|
+
readonly retries?: number;
|
|
25
|
+
constructor(message: string, opts?: {
|
|
26
|
+
retries?: number;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Tuning for {@link dispatchWorkers}. */
|
|
30
|
+
export interface DispatchOptions {
|
|
31
|
+
/** Max jobs to activate per job type per round (default 10). */
|
|
32
|
+
maxJobsPerActivation?: number;
|
|
33
|
+
/** Lock timeout handed to `activateJobs`, in ms (default 30_000). */
|
|
34
|
+
lockTimeoutMs?: number;
|
|
35
|
+
/** Worker name jobs are locked to (default `"bojtos"`). */
|
|
36
|
+
worker?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Safety cap on drain rounds (default 1000). A handler that keeps creating
|
|
39
|
+
* work (e.g. an unbounded loop in the model) would otherwise spin forever;
|
|
40
|
+
* exceeding the cap throws instead.
|
|
41
|
+
*/
|
|
42
|
+
maxRounds?: number;
|
|
43
|
+
}
|
|
44
|
+
/** What one {@link dispatchRound} pass did. */
|
|
45
|
+
export interface RoundResult {
|
|
46
|
+
/** The snapshot after this pass. */
|
|
47
|
+
snapshot: Snapshot;
|
|
48
|
+
/** How many jobs were completed or failed in this pass. */
|
|
49
|
+
handled: number;
|
|
50
|
+
}
|
|
51
|
+
/** What {@link dispatchWorkers} did. */
|
|
52
|
+
export interface DispatchResult {
|
|
53
|
+
/** The snapshot after the drain settled. */
|
|
54
|
+
snapshot: Snapshot;
|
|
55
|
+
/** How many jobs were completed or failed. */
|
|
56
|
+
handled: number;
|
|
57
|
+
/** How many activate rounds ran (including the final quiescent one). */
|
|
58
|
+
rounds: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Run one activate-and-handle pass: activate every registered job type's
|
|
62
|
+
* currently-`Created` jobs *first* (a snapshot of the token frontier), then hand
|
|
63
|
+
* each to its handler (complete on return, fail on throw). Jobs a handler
|
|
64
|
+
* unblocks downstream are deliberately *not* chased within the same round — they
|
|
65
|
+
* belong to the next frontier — so one round advances every live token by
|
|
66
|
+
* exactly one step. That makes this the animatable unit: drive it on a timer to
|
|
67
|
+
* watch the token(s) hop task-to-task. {@link dispatchWorkers} loops it to
|
|
68
|
+
* quiescence.
|
|
69
|
+
*/
|
|
70
|
+
export declare function dispatchRound(session: BojtosSession, workers: Record<string, JobHandler>, opts?: DispatchOptions): Promise<RoundResult>;
|
|
71
|
+
/**
|
|
72
|
+
* Drive an in-browser worker loop over a {@link BojtosSession}: repeatedly
|
|
73
|
+
* {@link dispatchRound} until a round handles nothing, so a whole process runs
|
|
74
|
+
* to quiescence in one call. Job types with no registered handler are simply
|
|
75
|
+
* left waiting.
|
|
76
|
+
*
|
|
77
|
+
* This is the dispatch half of the Bojtos runtime (ADR 0043 §8 step 3) — the
|
|
78
|
+
* "activate → JS handler → complete/fail" loop that makes the token move and the
|
|
79
|
+
* variable payload mutate as workers run.
|
|
80
|
+
*/
|
|
81
|
+
export declare function dispatchWorkers(session: BojtosSession, workers: Record<string, JobHandler>, opts?: DispatchOptions): Promise<DispatchResult>;
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throw from a {@link JobHandler} to fail a job with an explicit remaining
|
|
3
|
+
* `retries` count (default is `job.retries - 1`). With `retries: 0` the engine
|
|
4
|
+
* raises an incident, which surfaces in the snapshot's `incidentElementIds` —
|
|
5
|
+
* handy for demoing the failure path deterministically.
|
|
6
|
+
*/
|
|
7
|
+
export class JobFailure extends Error {
|
|
8
|
+
retries;
|
|
9
|
+
constructor(message, opts) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "JobFailure";
|
|
12
|
+
this.retries = opts?.retries;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function runOne(session, handler, job) {
|
|
16
|
+
let payload;
|
|
17
|
+
try {
|
|
18
|
+
// Only the handler and the serialization of its result are treated as a
|
|
19
|
+
// job failure: a handler that throws (or returns something unserializable)
|
|
20
|
+
// is the demo's own logic failing, so we translate it into `failJob`.
|
|
21
|
+
const out = await handler(job);
|
|
22
|
+
payload = JSON.stringify(out ?? {});
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
const retries = e instanceof JobFailure && e.retries !== undefined
|
|
26
|
+
? e.retries
|
|
27
|
+
: Math.max(0, job.retries - 1);
|
|
28
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
29
|
+
session.failJob(job.key, retries, message);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// An engine command failure (invalid JSON the engine rejects, ABI mismatch,
|
|
33
|
+
// internal engine error) is a real problem, not a handler failure — masking
|
|
34
|
+
// it as `failJob` would hide the bug and mutate engine state incorrectly, so
|
|
35
|
+
// we let it bubble to the caller.
|
|
36
|
+
session.completeJob(job.key, payload);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run one activate-and-handle pass: activate every registered job type's
|
|
40
|
+
* currently-`Created` jobs *first* (a snapshot of the token frontier), then hand
|
|
41
|
+
* each to its handler (complete on return, fail on throw). Jobs a handler
|
|
42
|
+
* unblocks downstream are deliberately *not* chased within the same round — they
|
|
43
|
+
* belong to the next frontier — so one round advances every live token by
|
|
44
|
+
* exactly one step. That makes this the animatable unit: drive it on a timer to
|
|
45
|
+
* watch the token(s) hop task-to-task. {@link dispatchWorkers} loops it to
|
|
46
|
+
* quiescence.
|
|
47
|
+
*/
|
|
48
|
+
export async function dispatchRound(session, workers, opts = {}) {
|
|
49
|
+
const maxJobs = opts.maxJobsPerActivation ?? 10;
|
|
50
|
+
const timeout = opts.lockTimeoutMs ?? 30_000;
|
|
51
|
+
const worker = opts.worker ?? "bojtos";
|
|
52
|
+
// Activation pass: lock the whole current frontier before running any handler,
|
|
53
|
+
// so a job a handler unblocks isn't also picked up this round (which would
|
|
54
|
+
// cascade the entire chain in a single "step").
|
|
55
|
+
const batch = [];
|
|
56
|
+
for (const [jobType, handler] of Object.entries(workers)) {
|
|
57
|
+
for (const job of session.activateJobs(jobType, maxJobs, timeout, worker)) {
|
|
58
|
+
batch.push({ handler, job });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Handle pass.
|
|
62
|
+
for (const { handler, job } of batch) {
|
|
63
|
+
await runOne(session, handler, job);
|
|
64
|
+
}
|
|
65
|
+
return { snapshot: session.snapshot(), handled: batch.length };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Drive an in-browser worker loop over a {@link BojtosSession}: repeatedly
|
|
69
|
+
* {@link dispatchRound} until a round handles nothing, so a whole process runs
|
|
70
|
+
* to quiescence in one call. Job types with no registered handler are simply
|
|
71
|
+
* left waiting.
|
|
72
|
+
*
|
|
73
|
+
* This is the dispatch half of the Bojtos runtime (ADR 0043 §8 step 3) — the
|
|
74
|
+
* "activate → JS handler → complete/fail" loop that makes the token move and the
|
|
75
|
+
* variable payload mutate as workers run.
|
|
76
|
+
*/
|
|
77
|
+
export async function dispatchWorkers(session, workers, opts = {}) {
|
|
78
|
+
const maxRounds = opts.maxRounds ?? 1000;
|
|
79
|
+
let handled = 0;
|
|
80
|
+
let rounds = 0;
|
|
81
|
+
for (;;) {
|
|
82
|
+
if (rounds >= maxRounds) {
|
|
83
|
+
throw new Error(`dispatchWorkers exceeded maxRounds (${maxRounds}) — a handler may be creating work without end`);
|
|
84
|
+
}
|
|
85
|
+
rounds++;
|
|
86
|
+
const round = await dispatchRound(session, workers, opts);
|
|
87
|
+
handled += round.handled;
|
|
88
|
+
if (round.handled === 0) {
|
|
89
|
+
return { snapshot: round.snapshot, handled, rounds };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nanobpm/bojtos-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic core of the Bojtos in-browser BPMN demo framework (ADR 0043): a single scenario runner over the @nanobpm/engine-wasm engine (deploy, start instances, complete/fail jobs, advance the clock, read snapshots and the event log), plus the engine's snapshot/event contract types. Consumed by @nanobpm/bojtos-react and the console test-run panel.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Magikcraft/nano-bpm",
|
|
10
|
+
"directory": "bojtos-kit"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./source": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"test": "npm run build && npm run test:ci",
|
|
31
|
+
"test:ci": "node --experimental-strip-types --test test/*.test.ts",
|
|
32
|
+
"prepack": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@nanobpm/engine-wasm": "^0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"typescript": "^5.6.3"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @nanobpm/bojtos-kit — the framework-agnostic core of the Bojtos demo
|
|
2
|
+
// framework (ADR 0043). Wraps the in-browser wasm engine as a single scenario
|
|
3
|
+
// runner and re-exports the engine's snapshot/event contract types.
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
ensureWasm,
|
|
7
|
+
createBojtosSession,
|
|
8
|
+
type BojtosSession,
|
|
9
|
+
type WasmSource,
|
|
10
|
+
} from "./session.js";
|
|
11
|
+
export {
|
|
12
|
+
dispatchWorkers,
|
|
13
|
+
dispatchRound,
|
|
14
|
+
JobFailure,
|
|
15
|
+
type JobHandler,
|
|
16
|
+
type JobResult,
|
|
17
|
+
type DispatchOptions,
|
|
18
|
+
type DispatchResult,
|
|
19
|
+
type RoundResult,
|
|
20
|
+
} from "./worker.js";
|
|
21
|
+
export type {
|
|
22
|
+
Snapshot,
|
|
23
|
+
InstanceDto,
|
|
24
|
+
JobDto,
|
|
25
|
+
ActivatedJob,
|
|
26
|
+
IncidentDto,
|
|
27
|
+
TimerDto,
|
|
28
|
+
ActiveEl,
|
|
29
|
+
WasmEvent,
|
|
30
|
+
} from "./types.js";
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import init, { type InitInput, TestEngine } from "@nanobpm/engine-wasm";
|
|
2
|
+
import type { ActivatedJob, Snapshot, WasmEvent } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// Lazily initialise the wasm module exactly once per page, no matter how many
|
|
5
|
+
// sessions are created. Mirrors the console's original `ensureWasm`.
|
|
6
|
+
let wasmReady: Promise<void> | null = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The source of the engine wasm binary. Under a bundler that understands
|
|
10
|
+
* `new URL(..., import.meta.url)` (e.g. Vite) the default loader needs no
|
|
11
|
+
* argument; pass an explicit `URL` / `Response` / bytes / `WebAssembly.Module`
|
|
12
|
+
* when the environment can't resolve it that way (Node, Jest, or the external-
|
|
13
|
+
* `.wasm` "wasmUrl" mode — ADR 0043 §3).
|
|
14
|
+
*/
|
|
15
|
+
export type WasmSource = InitInput;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initialise the wasm engine module (idempotent; safe to call repeatedly). The
|
|
19
|
+
* first successful call wins: a `source` passed to a later call is ignored once
|
|
20
|
+
* the module is already loading or loaded. Pass a `source` in environments where
|
|
21
|
+
* the default `import.meta.url` fetch can't resolve the binary
|
|
22
|
+
* (Node/Jest/webpack).
|
|
23
|
+
*
|
|
24
|
+
* If a load *fails*, the cached promise is cleared so a later call — e.g. one
|
|
25
|
+
* that supplies a working `WasmSource` after the default loader couldn't resolve
|
|
26
|
+
* the binary — can retry rather than being stuck on the first rejection.
|
|
27
|
+
*/
|
|
28
|
+
export function ensureWasm(source?: WasmSource): Promise<void> {
|
|
29
|
+
if (!wasmReady) {
|
|
30
|
+
wasmReady = init(
|
|
31
|
+
source === undefined ? undefined : { module_or_path: source },
|
|
32
|
+
)
|
|
33
|
+
.then(() => undefined)
|
|
34
|
+
.catch((e) => {
|
|
35
|
+
wasmReady = null;
|
|
36
|
+
throw e;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return wasmReady;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A headless handle to one in-browser engine instance: deploy a diagram, start
|
|
44
|
+
* instances, complete/fail jobs, advance the virtual clock, and read the event
|
|
45
|
+
* log. Every command returns the post-run {@link Snapshot}. This is the single
|
|
46
|
+
* scenario runner the Bojtos framework and the console both drive (ADR 0043 §8);
|
|
47
|
+
* framework bindings (`@nanobpm/bojtos-react`) own the reactive state on top.
|
|
48
|
+
*/
|
|
49
|
+
export interface BojtosSession {
|
|
50
|
+
/**
|
|
51
|
+
* Parse and deploy a BPMN resource. Returns the deployable process ids.
|
|
52
|
+
* Throws a JS error carrying the parse/deploy failure message.
|
|
53
|
+
*/
|
|
54
|
+
deploy(xml: string): { processIds: string[] };
|
|
55
|
+
/** Start an instance of `processId`, seeding it with `variablesJson`. */
|
|
56
|
+
createInstance(processId: string, variablesJson: string): Snapshot;
|
|
57
|
+
/**
|
|
58
|
+
* Activate up to `maxJobs` `Created` jobs of `jobType`, locking them to
|
|
59
|
+
* `worker` until `now + timeoutMs`. Returns the activated jobs (each carrying
|
|
60
|
+
* the instance's current variables) for a dispatch loop to hand to worker
|
|
61
|
+
* handlers. A job that is already activated is not re-returned.
|
|
62
|
+
*/
|
|
63
|
+
activateJobs(
|
|
64
|
+
jobType: string,
|
|
65
|
+
maxJobs: number,
|
|
66
|
+
timeoutMs: number,
|
|
67
|
+
worker: string,
|
|
68
|
+
): ActivatedJob[];
|
|
69
|
+
/** Complete a waiting job, merging `variablesJson` into the instance. */
|
|
70
|
+
completeJob(jobKey: string, variablesJson: string): Snapshot;
|
|
71
|
+
/** Fail a waiting job; with no retries left this raises an incident. */
|
|
72
|
+
failJob(jobKey: string, retries: number, message: string): Snapshot;
|
|
73
|
+
/**
|
|
74
|
+
* Correlate a message to any instance waiting on it: publishes `messageName`
|
|
75
|
+
* with `correlationKey` (the value the waiting subscription's `correlationKey`
|
|
76
|
+
* expression resolved to) and merges `variablesJson` into each correlated
|
|
77
|
+
* instance. Unblocks a message intermediate-catch / receive task without an
|
|
78
|
+
* external broker — the in-browser equivalent of an app publishing a message.
|
|
79
|
+
*/
|
|
80
|
+
correlateMessage(
|
|
81
|
+
messageName: string,
|
|
82
|
+
correlationKey: string,
|
|
83
|
+
variablesJson: string,
|
|
84
|
+
): Snapshot;
|
|
85
|
+
/** Advance the virtual clock by `byMs`, firing due timers and lapsed locks. */
|
|
86
|
+
advanceTime(byMs: number): Snapshot;
|
|
87
|
+
/**
|
|
88
|
+
* Discard all engine state (definitions, instances, jobs, timers, event log
|
|
89
|
+
* and clock), returning the underlying engine to its pristine state. The
|
|
90
|
+
* caller redeploys afterwards to begin a clean run — this is what lets a
|
|
91
|
+
* re-run start from zero completed instances instead of accumulating across
|
|
92
|
+
* runs.
|
|
93
|
+
*/
|
|
94
|
+
reset(): void;
|
|
95
|
+
/** The full ordered event log emitted so far. */
|
|
96
|
+
events(): WasmEvent[];
|
|
97
|
+
/** The current simulation state. */
|
|
98
|
+
snapshot(): Snapshot;
|
|
99
|
+
/** Release the underlying wasm engine. */
|
|
100
|
+
free(): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseSnapshot(json: string): Snapshot {
|
|
104
|
+
// The wasm engine is the schema authority; its JSON is the contract boundary.
|
|
105
|
+
return JSON.parse(json) as Snapshot;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class WasmBojtosSession implements BojtosSession {
|
|
109
|
+
private readonly engine: TestEngine;
|
|
110
|
+
|
|
111
|
+
constructor(engine: TestEngine) {
|
|
112
|
+
this.engine = engine;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
deploy(xml: string): { processIds: string[] } {
|
|
116
|
+
return JSON.parse(this.engine.deploy(xml)) as { processIds: string[] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
createInstance(processId: string, variablesJson: string): Snapshot {
|
|
120
|
+
return parseSnapshot(
|
|
121
|
+
this.engine.createInstance(processId, variablesJson || "{}"),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
activateJobs(
|
|
126
|
+
jobType: string,
|
|
127
|
+
maxJobs: number,
|
|
128
|
+
timeoutMs: number,
|
|
129
|
+
worker: string,
|
|
130
|
+
): ActivatedJob[] {
|
|
131
|
+
return JSON.parse(
|
|
132
|
+
this.engine.activateJobs(jobType, maxJobs, timeoutMs, worker),
|
|
133
|
+
) as ActivatedJob[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
completeJob(jobKey: string, variablesJson: string): Snapshot {
|
|
137
|
+
return parseSnapshot(this.engine.completeJob(jobKey, variablesJson || "{}"));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
failJob(jobKey: string, retries: number, message: string): Snapshot {
|
|
141
|
+
return parseSnapshot(this.engine.failJob(jobKey, retries, message));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
correlateMessage(
|
|
145
|
+
messageName: string,
|
|
146
|
+
correlationKey: string,
|
|
147
|
+
variablesJson: string,
|
|
148
|
+
): Snapshot {
|
|
149
|
+
return parseSnapshot(
|
|
150
|
+
this.engine.correlateMessage(
|
|
151
|
+
messageName,
|
|
152
|
+
correlationKey,
|
|
153
|
+
variablesJson || "{}",
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
advanceTime(byMs: number): Snapshot {
|
|
159
|
+
return parseSnapshot(this.engine.advanceTime(byMs));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
reset(): void {
|
|
163
|
+
this.engine.reset();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
events(): WasmEvent[] {
|
|
167
|
+
return JSON.parse(this.engine.events()) as WasmEvent[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
snapshot(): Snapshot {
|
|
171
|
+
return parseSnapshot(this.engine.snapshot());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
free(): void {
|
|
175
|
+
this.engine.free();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create a fresh headless engine session. Ensures the wasm module is loaded
|
|
181
|
+
* (once per page), then constructs a new {@link TestEngine}. The virtual clock
|
|
182
|
+
* starts at 0; deploy a diagram before starting instances. Pass a `wasm` source
|
|
183
|
+
* in environments where the default `import.meta.url` loader can't resolve the
|
|
184
|
+
* binary (Node/Jest, or the external-`.wasm` mode — ADR 0043 §3).
|
|
185
|
+
*/
|
|
186
|
+
export async function createBojtosSession(opts?: {
|
|
187
|
+
wasm?: WasmSource;
|
|
188
|
+
}): Promise<BojtosSession> {
|
|
189
|
+
await ensureWasm(opts?.wasm);
|
|
190
|
+
return new WasmBojtosSession(new TestEngine());
|
|
191
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The shapes the in-browser engine (`@nanobpm/engine-wasm`) emits from its
|
|
2
|
+
// JSON string surface — `deploy` / `createInstance` / `completeJob` / `failJob`
|
|
3
|
+
// / `advanceTime` / `snapshot` return a `Snapshot`, and `events()` returns a
|
|
4
|
+
// `WasmEvent[]`. These describe the engine's public contract, so they live in
|
|
5
|
+
// the framework-agnostic kit and are re-exported by the React binding.
|
|
6
|
+
|
|
7
|
+
/** One active element token within an instance. */
|
|
8
|
+
export interface ActiveEl {
|
|
9
|
+
key: string;
|
|
10
|
+
elementId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A process instance's live state. */
|
|
14
|
+
export interface InstanceDto {
|
|
15
|
+
key: string;
|
|
16
|
+
processId: string;
|
|
17
|
+
state: string;
|
|
18
|
+
completed: boolean;
|
|
19
|
+
activeElements: ActiveEl[];
|
|
20
|
+
variables: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A job waiting for a worker. */
|
|
24
|
+
export interface JobDto {
|
|
25
|
+
key: string;
|
|
26
|
+
instanceKey: string;
|
|
27
|
+
elementId: string;
|
|
28
|
+
jobType: string;
|
|
29
|
+
state: string;
|
|
30
|
+
retries: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A job locked to a worker by {@link BojtosSession.activateJobs}, ready to hand
|
|
35
|
+
* to a {@link JobHandler}. Carries the instance's current `variables` so a
|
|
36
|
+
* handler can compute its output from the live payload. `key` is what
|
|
37
|
+
* `completeJob` / `failJob` take.
|
|
38
|
+
*/
|
|
39
|
+
export interface ActivatedJob {
|
|
40
|
+
key: string;
|
|
41
|
+
type: string;
|
|
42
|
+
instanceKey: string;
|
|
43
|
+
elementId: string;
|
|
44
|
+
retries: number;
|
|
45
|
+
variables: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An incident raised on an element. */
|
|
49
|
+
export interface IncidentDto {
|
|
50
|
+
key: string;
|
|
51
|
+
instanceKey: string;
|
|
52
|
+
elementId: string;
|
|
53
|
+
kind: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A pending timer. */
|
|
58
|
+
export interface TimerDto {
|
|
59
|
+
key: string;
|
|
60
|
+
instanceKey: string;
|
|
61
|
+
elementId: string;
|
|
62
|
+
dueAt: number;
|
|
63
|
+
dueInMs: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The full simulation state returned by every engine command. `activeElementIds`
|
|
68
|
+
* / `incidentElementIds` drive the token/incident highlight (the visual
|
|
69
|
+
* contract, ADR 0043 §4); `instances[].variables` is the live payload.
|
|
70
|
+
*/
|
|
71
|
+
export interface Snapshot {
|
|
72
|
+
now: number;
|
|
73
|
+
eventCount: number;
|
|
74
|
+
/** Present on a `createInstance` snapshot: the new instance key. */
|
|
75
|
+
created?: string;
|
|
76
|
+
totalInstances: number;
|
|
77
|
+
completedInstances: number;
|
|
78
|
+
instances: InstanceDto[];
|
|
79
|
+
jobs: JobDto[];
|
|
80
|
+
incidents: IncidentDto[];
|
|
81
|
+
timers: TimerDto[];
|
|
82
|
+
activeElementIds: string[];
|
|
83
|
+
incidentElementIds: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A flattened wasm engine event: `{ seq, now, type, ...snake_case fields }`.
|
|
88
|
+
* Produced by `TestEngine.events()` and folded into a trace view by consumers.
|
|
89
|
+
*/
|
|
90
|
+
export interface WasmEvent {
|
|
91
|
+
seq: number;
|
|
92
|
+
now: number;
|
|
93
|
+
type: string;
|
|
94
|
+
[k: string]: unknown;
|
|
95
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { BojtosSession } from "./session.js";
|
|
2
|
+
import type { ActivatedJob, Snapshot } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The variables a handler merges into its instance on completion. Return an
|
|
6
|
+
* object to merge it, or `void`/`undefined` to complete with no new variables.
|
|
7
|
+
* To fail a job instead, throw — a plain `Error` fails it with `retries - 1`
|
|
8
|
+
* (an incident once retries reach 0); throw a {@link JobFailure} to set the
|
|
9
|
+
* remaining retries explicitly.
|
|
10
|
+
*/
|
|
11
|
+
export type JobResult = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A worker for one job type: given an {@link ActivatedJob} (carrying the
|
|
15
|
+
* instance's current variables), compute the output variables to merge on
|
|
16
|
+
* completion. May be async. Throw to fail the job.
|
|
17
|
+
*/
|
|
18
|
+
export type JobHandler = (
|
|
19
|
+
job: ActivatedJob,
|
|
20
|
+
) => JobResult | void | Promise<JobResult | void>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Throw from a {@link JobHandler} to fail a job with an explicit remaining
|
|
24
|
+
* `retries` count (default is `job.retries - 1`). With `retries: 0` the engine
|
|
25
|
+
* raises an incident, which surfaces in the snapshot's `incidentElementIds` —
|
|
26
|
+
* handy for demoing the failure path deterministically.
|
|
27
|
+
*/
|
|
28
|
+
export class JobFailure extends Error {
|
|
29
|
+
readonly retries?: number;
|
|
30
|
+
constructor(message: string, opts?: { retries?: number }) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "JobFailure";
|
|
33
|
+
this.retries = opts?.retries;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Tuning for {@link dispatchWorkers}. */
|
|
38
|
+
export interface DispatchOptions {
|
|
39
|
+
/** Max jobs to activate per job type per round (default 10). */
|
|
40
|
+
maxJobsPerActivation?: number;
|
|
41
|
+
/** Lock timeout handed to `activateJobs`, in ms (default 30_000). */
|
|
42
|
+
lockTimeoutMs?: number;
|
|
43
|
+
/** Worker name jobs are locked to (default `"bojtos"`). */
|
|
44
|
+
worker?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Safety cap on drain rounds (default 1000). A handler that keeps creating
|
|
47
|
+
* work (e.g. an unbounded loop in the model) would otherwise spin forever;
|
|
48
|
+
* exceeding the cap throws instead.
|
|
49
|
+
*/
|
|
50
|
+
maxRounds?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What one {@link dispatchRound} pass did. */
|
|
54
|
+
export interface RoundResult {
|
|
55
|
+
/** The snapshot after this pass. */
|
|
56
|
+
snapshot: Snapshot;
|
|
57
|
+
/** How many jobs were completed or failed in this pass. */
|
|
58
|
+
handled: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What {@link dispatchWorkers} did. */
|
|
62
|
+
export interface DispatchResult {
|
|
63
|
+
/** The snapshot after the drain settled. */
|
|
64
|
+
snapshot: Snapshot;
|
|
65
|
+
/** How many jobs were completed or failed. */
|
|
66
|
+
handled: number;
|
|
67
|
+
/** How many activate rounds ran (including the final quiescent one). */
|
|
68
|
+
rounds: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runOne(
|
|
72
|
+
session: BojtosSession,
|
|
73
|
+
handler: JobHandler,
|
|
74
|
+
job: ActivatedJob,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
let payload: string;
|
|
77
|
+
try {
|
|
78
|
+
// Only the handler and the serialization of its result are treated as a
|
|
79
|
+
// job failure: a handler that throws (or returns something unserializable)
|
|
80
|
+
// is the demo's own logic failing, so we translate it into `failJob`.
|
|
81
|
+
const out = await handler(job);
|
|
82
|
+
payload = JSON.stringify(out ?? {});
|
|
83
|
+
} catch (e) {
|
|
84
|
+
const retries =
|
|
85
|
+
e instanceof JobFailure && e.retries !== undefined
|
|
86
|
+
? e.retries
|
|
87
|
+
: Math.max(0, job.retries - 1);
|
|
88
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
89
|
+
session.failJob(job.key, retries, message);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// An engine command failure (invalid JSON the engine rejects, ABI mismatch,
|
|
93
|
+
// internal engine error) is a real problem, not a handler failure — masking
|
|
94
|
+
// it as `failJob` would hide the bug and mutate engine state incorrectly, so
|
|
95
|
+
// we let it bubble to the caller.
|
|
96
|
+
session.completeJob(job.key, payload);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Run one activate-and-handle pass: activate every registered job type's
|
|
101
|
+
* currently-`Created` jobs *first* (a snapshot of the token frontier), then hand
|
|
102
|
+
* each to its handler (complete on return, fail on throw). Jobs a handler
|
|
103
|
+
* unblocks downstream are deliberately *not* chased within the same round — they
|
|
104
|
+
* belong to the next frontier — so one round advances every live token by
|
|
105
|
+
* exactly one step. That makes this the animatable unit: drive it on a timer to
|
|
106
|
+
* watch the token(s) hop task-to-task. {@link dispatchWorkers} loops it to
|
|
107
|
+
* quiescence.
|
|
108
|
+
*/
|
|
109
|
+
export async function dispatchRound(
|
|
110
|
+
session: BojtosSession,
|
|
111
|
+
workers: Record<string, JobHandler>,
|
|
112
|
+
opts: DispatchOptions = {},
|
|
113
|
+
): Promise<RoundResult> {
|
|
114
|
+
const maxJobs = opts.maxJobsPerActivation ?? 10;
|
|
115
|
+
const timeout = opts.lockTimeoutMs ?? 30_000;
|
|
116
|
+
const worker = opts.worker ?? "bojtos";
|
|
117
|
+
// Activation pass: lock the whole current frontier before running any handler,
|
|
118
|
+
// so a job a handler unblocks isn't also picked up this round (which would
|
|
119
|
+
// cascade the entire chain in a single "step").
|
|
120
|
+
const batch: { handler: JobHandler; job: ActivatedJob }[] = [];
|
|
121
|
+
for (const [jobType, handler] of Object.entries(workers)) {
|
|
122
|
+
for (const job of session.activateJobs(jobType, maxJobs, timeout, worker)) {
|
|
123
|
+
batch.push({ handler, job });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Handle pass.
|
|
127
|
+
for (const { handler, job } of batch) {
|
|
128
|
+
await runOne(session, handler, job);
|
|
129
|
+
}
|
|
130
|
+
return { snapshot: session.snapshot(), handled: batch.length };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Drive an in-browser worker loop over a {@link BojtosSession}: repeatedly
|
|
135
|
+
* {@link dispatchRound} until a round handles nothing, so a whole process runs
|
|
136
|
+
* to quiescence in one call. Job types with no registered handler are simply
|
|
137
|
+
* left waiting.
|
|
138
|
+
*
|
|
139
|
+
* This is the dispatch half of the Bojtos runtime (ADR 0043 §8 step 3) — the
|
|
140
|
+
* "activate → JS handler → complete/fail" loop that makes the token move and the
|
|
141
|
+
* variable payload mutate as workers run.
|
|
142
|
+
*/
|
|
143
|
+
export async function dispatchWorkers(
|
|
144
|
+
session: BojtosSession,
|
|
145
|
+
workers: Record<string, JobHandler>,
|
|
146
|
+
opts: DispatchOptions = {},
|
|
147
|
+
): Promise<DispatchResult> {
|
|
148
|
+
const maxRounds = opts.maxRounds ?? 1000;
|
|
149
|
+
let handled = 0;
|
|
150
|
+
let rounds = 0;
|
|
151
|
+
for (;;) {
|
|
152
|
+
if (rounds >= maxRounds) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`dispatchWorkers exceeded maxRounds (${maxRounds}) — a handler may be creating work without end`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
rounds++;
|
|
158
|
+
const round = await dispatchRound(session, workers, opts);
|
|
159
|
+
handled += round.handled;
|
|
160
|
+
if (round.handled === 0) {
|
|
161
|
+
return { snapshot: round.snapshot, handled, rounds };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|