@nanobpm/bojtos-kit 0.4.0 → 0.6.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 +25 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +5 -1
- package/dist/trace.d.ts +97 -0
- package/dist/trace.js +185 -0
- package/dist/types.d.ts +9 -0
- package/dist/worker.d.ts +68 -0
- package/dist/worker.js +87 -4
- package/package.json +1 -1
- package/src/index.ts +30 -0
- package/src/trace.ts +291 -0
- package/src/types.ts +9 -0
- package/src/worker.ts +153 -4
package/README.md
CHANGED
|
@@ -27,7 +27,30 @@ Every command returns the post-run `Snapshot`: `activeElementIds` /
|
|
|
27
27
|
For React, use [`@nanobpm/bojtos-react`](../bojtos-react), which owns the session
|
|
28
28
|
lifecycle and reactive state on top of this kit.
|
|
29
29
|
|
|
30
|
+
## Trace model
|
|
31
|
+
|
|
32
|
+
The kit also holds the framework-agnostic **trace model** the shared
|
|
33
|
+
`<TraceTimeline>` (in `@nanobpm/bojtos-react`) renders — one normalized
|
|
34
|
+
row/turn-group model plus the two adapters that map a source into it, so the two
|
|
35
|
+
formerly forked timelines share one fold instead of drifting apart:
|
|
36
|
+
|
|
37
|
+
- **`foldEngineEvents(events)`** — the engine-event fold: a `WasmEvent[]` (from
|
|
38
|
+
`session.events()` / `useBojtos().events`) → normalized `TraceRow[]`, keeping the
|
|
39
|
+
run's milestones and dropping low-signal lifecycle noise. The non-agentic /
|
|
40
|
+
test-view case.
|
|
41
|
+
- **`traceEntriesToRows(entries)`** — the handler-emitted adapter: agent/tool/turn
|
|
42
|
+
`TraceEntry` lines (with the additive `turn` grouping field) → `TraceRow[]`. The
|
|
43
|
+
agentic web-demo case.
|
|
44
|
+
- **`buildTraceItems(rows)`** — folds consecutive same-`turn` rows into
|
|
45
|
+
`TraceTurnGroup`s; rows with no `turn` stay flat. `isTraceTurnGroup` narrows an
|
|
46
|
+
item. This is the grouping the view consumes.
|
|
47
|
+
|
|
48
|
+
It is pure and React-free (no React import in the kit), keeping the presentational
|
|
49
|
+
layer thin.
|
|
50
|
+
|
|
30
51
|
## Build
|
|
31
52
|
|
|
32
|
-
`dist/` (the tsc-emitted JS + `.d.ts`) is
|
|
33
|
-
|
|
53
|
+
`dist/` (the tsc-emitted JS + `.d.ts`) is what ships, built by `prepack` on
|
|
54
|
+
publish and by `npm test` locally. It is **not** committed — `.gitignore` covers
|
|
55
|
+
it — so build before pointing a `file:` consumer at this workspace. Regenerate
|
|
56
|
+
with `npm run build`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { ensureWasm, createBojtosSession, type BojtosSession, type WasmSource, } from "./session.js";
|
|
2
|
-
export { dispatchWorkers, dispatchRound, JobFailure, type JobHandler, type JobResult, type AgentHandler, type DispatchOptions, type DispatchResult, type RoundResult, } from "./worker.js";
|
|
3
|
-
export
|
|
2
|
+
export { dispatchWorkers, dispatchRound, settleReason, unhandledJobTypes, JobFailure, type JobHandler, type JobResult, type AgentHandler, type DispatchOptions, type DispatchResult, type RoundResult, type SettleReason, } from "./worker.js";
|
|
3
|
+
export { buildTraceItems, isTraceTurnGroup, foldEngineEvents, traceEntriesToRows, } from "./trace.js";
|
|
4
|
+
export type { TraceRowKind, TraceEntry, TraceRow, TraceTurnGroup, TraceItem, TraceAdapter, } from "./trace.js";
|
|
5
|
+
export type { Snapshot, InstanceDto, JobDto, ActivatedJob, IncidentDto, TimerDto, UserTaskDto, MessageSubscriptionDto, SignalSubscriptionDto, ElementStatDto, SequenceFlowDto, DecisionInstanceDto, ActiveEl, ActivateInstruction, AgentActivation, AgentResult, WasmEvent, } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -2,4 +2,8 @@
|
|
|
2
2
|
// framework (ADR 0043). Wraps the in-browser wasm engine as a single scenario
|
|
3
3
|
// runner and re-exports the engine's snapshot/event contract types.
|
|
4
4
|
export { ensureWasm, createBojtosSession, } from "./session.js";
|
|
5
|
-
export { dispatchWorkers, dispatchRound, JobFailure, } from "./worker.js";
|
|
5
|
+
export { dispatchWorkers, dispatchRound, settleReason, unhandledJobTypes, JobFailure, } from "./worker.js";
|
|
6
|
+
// The shared trace model + both adapters (engine-event fold and handler-emitted
|
|
7
|
+
// `TraceEntry`) that retired the two forked `TraceTimeline` copies (#9). Pure and
|
|
8
|
+
// React-free — the presentational component lives in @nanobpm/bojtos-react.
|
|
9
|
+
export { buildTraceItems, isTraceTurnGroup, foldEngineEvents, traceEntriesToRows, } from "./trace.js";
|
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { WasmEvent } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Semantic classification of a trace row, driving how the view styles it (the
|
|
4
|
+
* `log-<kind>` class the two forked timelines already keyed off) and which
|
|
5
|
+
* affordance it carries. Framework-agnostic — an engine-event fold and an
|
|
6
|
+
* agentic handler stream both land in this shared vocabulary:
|
|
7
|
+
*
|
|
8
|
+
* - `start` — the run/instance began.
|
|
9
|
+
* - `agent` — an agent decision, or a tool the agent activated this turn.
|
|
10
|
+
* - `llm` — a raw model reply.
|
|
11
|
+
* - `tool` — a tool/handler log line (a job running, a timer, a message).
|
|
12
|
+
* - `human` — a user task awaiting or completed by a person.
|
|
13
|
+
* - `done` — the final outcome (the instance completed/terminated).
|
|
14
|
+
* - `error` — a failure, incident, or thrown error.
|
|
15
|
+
* - `vars` — a variables/result update (e.g. what a tool returned).
|
|
16
|
+
*/
|
|
17
|
+
export type TraceRowKind = "start" | "agent" | "llm" | "tool" | "human" | "done" | "error" | "vars";
|
|
18
|
+
/**
|
|
19
|
+
* A source line before it is placed in the normalized model. This is the shape a
|
|
20
|
+
* handler emits (the web-demo framework's `TraceEntry`): `kind`/`text` are all a
|
|
21
|
+
* plain consumer needs; every other field is additive and safe to ignore.
|
|
22
|
+
*/
|
|
23
|
+
export interface TraceEntry {
|
|
24
|
+
kind: TraceRowKind;
|
|
25
|
+
/** The human-readable line. */
|
|
26
|
+
text: string;
|
|
27
|
+
/**
|
|
28
|
+
* Stable id for an entry that updates in place — a streaming completion grows
|
|
29
|
+
* one line rather than spamming forty.
|
|
30
|
+
*/
|
|
31
|
+
key?: string;
|
|
32
|
+
/** True while the entry is still being produced (renders a spinner). */
|
|
33
|
+
pending?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Groups every entry produced by one agent turn together (the streamed LLM
|
|
36
|
+
* reply, each tool it activated, and that tool's result). Consecutive entries
|
|
37
|
+
* sharing a `turn` fold into one turn card; entries with no `turn` render as
|
|
38
|
+
* plain rows in their original order.
|
|
39
|
+
*/
|
|
40
|
+
turn?: number;
|
|
41
|
+
/** The BPMN element (tool or task) this entry concerns. */
|
|
42
|
+
elementId?: string;
|
|
43
|
+
/** Arguments supplied when activating a tool — the coerced values, not the raw reply. */
|
|
44
|
+
args?: Record<string, unknown>;
|
|
45
|
+
/** What a tool/handler returned, paired with its activation by `elementId`. */
|
|
46
|
+
result?: unknown;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A normalized row: a {@link TraceEntry} stamped with a stable, monotonic `id`.
|
|
50
|
+
* The `id` is what the view keys off and what pairs a tool's result with its
|
|
51
|
+
* activation and orders loose lines within a turn.
|
|
52
|
+
*/
|
|
53
|
+
export interface TraceRow extends TraceEntry {
|
|
54
|
+
id: number;
|
|
55
|
+
}
|
|
56
|
+
/** Consecutive same-turn rows, folded into one group by {@link buildTraceItems}. */
|
|
57
|
+
export interface TraceTurnGroup {
|
|
58
|
+
turn: number;
|
|
59
|
+
rows: TraceRow[];
|
|
60
|
+
}
|
|
61
|
+
/** A top-level item the view renders: either a plain row or a turn group. */
|
|
62
|
+
export type TraceItem = TraceRow | TraceTurnGroup;
|
|
63
|
+
/** Narrow a {@link TraceItem} to a {@link TraceTurnGroup}. */
|
|
64
|
+
export declare function isTraceTurnGroup(item: TraceItem): item is TraceTurnGroup;
|
|
65
|
+
/**
|
|
66
|
+
* An adapter maps a source (`WasmEvent[]`, a `TraceEntry[]`, …) into the shared
|
|
67
|
+
* normalized row model. Both built-in adapters — {@link foldEngineEvents} and
|
|
68
|
+
* {@link traceEntriesToRows} — satisfy this; a consumer can supply its own for a
|
|
69
|
+
* bespoke source.
|
|
70
|
+
*/
|
|
71
|
+
export type TraceAdapter<TSource> = (source: TSource) => TraceRow[];
|
|
72
|
+
/**
|
|
73
|
+
* Fold a flat list of normalized rows into the view model: consecutive rows
|
|
74
|
+
* sharing a `turn` become one {@link TraceTurnGroup}; everything else stays a
|
|
75
|
+
* plain row in its original order. A row with no `turn` breaks the current group,
|
|
76
|
+
* so an interleaved non-turn line never gets swallowed into a card. This is the
|
|
77
|
+
* grouping both forked timelines did by hand, lifted into the shared kit.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildTraceItems(rows: TraceRow[]): TraceItem[];
|
|
80
|
+
/**
|
|
81
|
+
* The handler-emitted `TraceEntry` adapter: stamp each entry with a stable `id`
|
|
82
|
+
* (its index) to lift it into a {@link TraceRow}. The entries already carry the
|
|
83
|
+
* additive `turn`/`elementId`/`args`/`result` fields, so {@link buildTraceItems}
|
|
84
|
+
* over the result reproduces the agentic turn-grouped card view without any
|
|
85
|
+
* re-forked grouping logic. The input is never mutated.
|
|
86
|
+
*/
|
|
87
|
+
export declare function traceEntriesToRows(entries: readonly TraceEntry[]): TraceRow[];
|
|
88
|
+
/**
|
|
89
|
+
* The engine-event fold adapter: map a `WasmEvent[]` (the flattened
|
|
90
|
+
* `{ seq, now, type, …snake_case }` stream from `useBojtos().events`) into
|
|
91
|
+
* normalized rows, keeping only the meaningful milestones (see
|
|
92
|
+
* {@link ENGINE_EVENT_RULES}). Each row's `id` is the event's `seq`, so ids stay
|
|
93
|
+
* stable and monotonic across re-reads of a growing log. Engine events carry no
|
|
94
|
+
* turn, so {@link buildTraceItems} over the result is a flat list of rows — the
|
|
95
|
+
* non-agentic test-view shape.
|
|
96
|
+
*/
|
|
97
|
+
export declare function foldEngineEvents(events: readonly WasmEvent[]): TraceRow[];
|
package/dist/trace.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The framework-agnostic trace model shared by the Bojtos demo framework and the
|
|
2
|
+
// console test-view — the single source that retired the two drifted, forked
|
|
3
|
+
// `TraceTimeline` copies (nanobpm/bojtos#9). It defines one normalized row/turn
|
|
4
|
+
// model plus the two adapters that map a source into it:
|
|
5
|
+
//
|
|
6
|
+
// 1. the **engine-event fold** — `WasmEvent[]` (from `useBojtos().events`) →
|
|
7
|
+
// rows, covering the non-agentic / test-view case; and
|
|
8
|
+
// 2. the **handler-emitted `TraceEntry`** adapter — the agent/tool/turn entries
|
|
9
|
+
// (with the additive `turn` grouping field) → rows, covering the agentic
|
|
10
|
+
// web-demo case.
|
|
11
|
+
//
|
|
12
|
+
// It is deliberately React-free: the presentational component (`TraceTimeline` in
|
|
13
|
+
// `@nanobpm/bojtos-react`) renders this model, keeping the view layer thin.
|
|
14
|
+
/** Narrow a {@link TraceItem} to a {@link TraceTurnGroup}. */
|
|
15
|
+
export function isTraceTurnGroup(item) {
|
|
16
|
+
return item.rows !== undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Fold a flat list of normalized rows into the view model: consecutive rows
|
|
20
|
+
* sharing a `turn` become one {@link TraceTurnGroup}; everything else stays a
|
|
21
|
+
* plain row in its original order. A row with no `turn` breaks the current group,
|
|
22
|
+
* so an interleaved non-turn line never gets swallowed into a card. This is the
|
|
23
|
+
* grouping both forked timelines did by hand, lifted into the shared kit.
|
|
24
|
+
*/
|
|
25
|
+
export function buildTraceItems(rows) {
|
|
26
|
+
const items = [];
|
|
27
|
+
let current = null;
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
if (row.turn !== undefined) {
|
|
30
|
+
if (current && current.turn === row.turn) {
|
|
31
|
+
current.rows.push(row);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
current = { turn: row.turn, rows: [row] };
|
|
35
|
+
items.push(current);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
current = null;
|
|
40
|
+
items.push(row);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return items;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The handler-emitted `TraceEntry` adapter: stamp each entry with a stable `id`
|
|
47
|
+
* (its index) to lift it into a {@link TraceRow}. The entries already carry the
|
|
48
|
+
* additive `turn`/`elementId`/`args`/`result` fields, so {@link buildTraceItems}
|
|
49
|
+
* over the result reproduces the agentic turn-grouped card view without any
|
|
50
|
+
* re-forked grouping logic. The input is never mutated.
|
|
51
|
+
*/
|
|
52
|
+
export function traceEntriesToRows(entries) {
|
|
53
|
+
return entries.map((entry, id) => ({ ...entry, id }));
|
|
54
|
+
}
|
|
55
|
+
/** Read a string field off a `WasmEvent`'s open payload, or `undefined`. */
|
|
56
|
+
function str(ev, key) {
|
|
57
|
+
const v = ev[key];
|
|
58
|
+
return typeof v === "string" ? v : undefined;
|
|
59
|
+
}
|
|
60
|
+
/** The element this event concerns, if it names one — for pairing/labelling. */
|
|
61
|
+
function elementOf(ev) {
|
|
62
|
+
return str(ev, "element_id");
|
|
63
|
+
}
|
|
64
|
+
const ENGINE_EVENT_RULES = {
|
|
65
|
+
ProcessInstanceCreated: {
|
|
66
|
+
kind: "start",
|
|
67
|
+
text: (ev) => `Process ${str(ev, "process_id") ?? "instance"} started`,
|
|
68
|
+
},
|
|
69
|
+
JobCreated: {
|
|
70
|
+
kind: "tool",
|
|
71
|
+
text: (ev) => `Job ${str(ev, "job_type") ?? ""} created`.trim() +
|
|
72
|
+
(elementOf(ev) ? ` on ${elementOf(ev)}` : ""),
|
|
73
|
+
},
|
|
74
|
+
JobCompleted: {
|
|
75
|
+
kind: "vars",
|
|
76
|
+
text: (ev) => `Job ${str(ev, "job_type") ?? ""} completed`.trim(),
|
|
77
|
+
},
|
|
78
|
+
JobFailed: {
|
|
79
|
+
kind: "error",
|
|
80
|
+
text: (ev) => `Job ${str(ev, "job_type") ?? ""} failed`.trim(),
|
|
81
|
+
},
|
|
82
|
+
JobErrorThrown: {
|
|
83
|
+
kind: "error",
|
|
84
|
+
text: (ev) => `Job threw error ${str(ev, "error_code") ?? ""}`.trim() +
|
|
85
|
+
(elementOf(ev) ? ` on ${elementOf(ev)}` : ""),
|
|
86
|
+
},
|
|
87
|
+
IncidentRaised: {
|
|
88
|
+
kind: "error",
|
|
89
|
+
text: (ev) => `Incident on ${elementOf(ev) ?? "instance"}` +
|
|
90
|
+
(str(ev, "reason") ? `: ${str(ev, "reason")}` : ""),
|
|
91
|
+
},
|
|
92
|
+
IncidentResolved: {
|
|
93
|
+
kind: "tool",
|
|
94
|
+
text: (ev) => `Incident resolved on ${elementOf(ev) ?? "instance"}`,
|
|
95
|
+
},
|
|
96
|
+
UserTaskCreated: {
|
|
97
|
+
kind: "human",
|
|
98
|
+
text: (ev) => `User task ${elementOf(ev) ?? ""} awaiting a human`.trim(),
|
|
99
|
+
},
|
|
100
|
+
UserTaskAssigned: {
|
|
101
|
+
kind: "human",
|
|
102
|
+
text: (ev) => `User task ${elementOf(ev) ?? ""} assigned`.trim() +
|
|
103
|
+
(str(ev, "assignee") ? ` to ${str(ev, "assignee")}` : ""),
|
|
104
|
+
},
|
|
105
|
+
UserTaskCompleted: {
|
|
106
|
+
kind: "human",
|
|
107
|
+
text: (ev) => `User task ${elementOf(ev) ?? ""} completed`.trim(),
|
|
108
|
+
},
|
|
109
|
+
UserTaskCanceled: {
|
|
110
|
+
kind: "human",
|
|
111
|
+
text: (ev) => `User task ${elementOf(ev) ?? ""} canceled`.trim(),
|
|
112
|
+
},
|
|
113
|
+
TimerCreated: {
|
|
114
|
+
kind: "tool",
|
|
115
|
+
text: (ev) => `Timer set on ${elementOf(ev) ?? "instance"}`,
|
|
116
|
+
},
|
|
117
|
+
TimerTriggered: {
|
|
118
|
+
kind: "tool",
|
|
119
|
+
text: (ev) => `Timer fired on ${elementOf(ev) ?? "instance"}`,
|
|
120
|
+
},
|
|
121
|
+
MessagePublished: {
|
|
122
|
+
kind: "tool",
|
|
123
|
+
text: (ev) => `Message ${str(ev, "message_name") ?? ""} published`.trim(),
|
|
124
|
+
},
|
|
125
|
+
MessageCorrelated: {
|
|
126
|
+
kind: "tool",
|
|
127
|
+
text: (ev) => `Message ${str(ev, "message_name") ?? ""} correlated`.trim(),
|
|
128
|
+
},
|
|
129
|
+
SignalBroadcast: {
|
|
130
|
+
kind: "tool",
|
|
131
|
+
text: (ev) => `Signal ${str(ev, "signal_name") ?? ""} broadcast`.trim(),
|
|
132
|
+
},
|
|
133
|
+
SignalCorrelated: {
|
|
134
|
+
kind: "tool",
|
|
135
|
+
text: (ev) => `Signal ${str(ev, "signal_name") ?? ""} correlated`.trim(),
|
|
136
|
+
},
|
|
137
|
+
AdHocActivated: {
|
|
138
|
+
kind: "agent",
|
|
139
|
+
text: (ev) => `Ad-hoc sub-process ${elementOf(ev) ?? ""} activated`.trim(),
|
|
140
|
+
},
|
|
141
|
+
AdHocToolActivated: {
|
|
142
|
+
kind: "agent",
|
|
143
|
+
text: (ev) => `Tool ${elementOf(ev) ?? ""} activated`.trim(),
|
|
144
|
+
},
|
|
145
|
+
AdHocToolCompleted: {
|
|
146
|
+
kind: "vars",
|
|
147
|
+
text: (ev) => `Tool ${elementOf(ev) ?? ""} returned`.trim(),
|
|
148
|
+
},
|
|
149
|
+
AdHocCompleted: {
|
|
150
|
+
kind: "agent",
|
|
151
|
+
text: (ev) => `Ad-hoc sub-process ${elementOf(ev) ?? ""} completed`.trim(),
|
|
152
|
+
},
|
|
153
|
+
ProcessInstanceCompleted: {
|
|
154
|
+
kind: "done",
|
|
155
|
+
text: () => "Process completed",
|
|
156
|
+
},
|
|
157
|
+
ProcessInstanceTerminated: {
|
|
158
|
+
kind: "error",
|
|
159
|
+
text: () => "Process terminated",
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* The engine-event fold adapter: map a `WasmEvent[]` (the flattened
|
|
164
|
+
* `{ seq, now, type, …snake_case }` stream from `useBojtos().events`) into
|
|
165
|
+
* normalized rows, keeping only the meaningful milestones (see
|
|
166
|
+
* {@link ENGINE_EVENT_RULES}). Each row's `id` is the event's `seq`, so ids stay
|
|
167
|
+
* stable and monotonic across re-reads of a growing log. Engine events carry no
|
|
168
|
+
* turn, so {@link buildTraceItems} over the result is a flat list of rows — the
|
|
169
|
+
* non-agentic test-view shape.
|
|
170
|
+
*/
|
|
171
|
+
export function foldEngineEvents(events) {
|
|
172
|
+
const rows = [];
|
|
173
|
+
for (const ev of events) {
|
|
174
|
+
const rule = ENGINE_EVENT_RULES[ev.type];
|
|
175
|
+
if (!rule)
|
|
176
|
+
continue;
|
|
177
|
+
rows.push({
|
|
178
|
+
id: ev.seq,
|
|
179
|
+
kind: rule.kind,
|
|
180
|
+
text: rule.text(ev),
|
|
181
|
+
elementId: elementOf(ev),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return rows;
|
|
185
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -169,6 +169,15 @@ export interface Snapshot {
|
|
|
169
169
|
signalSubscriptions: SignalSubscriptionDto[];
|
|
170
170
|
elementStats: ElementStatDto[];
|
|
171
171
|
takenSequenceFlows: SequenceFlowDto[];
|
|
172
|
+
/**
|
|
173
|
+
* Evaluated DMN decisions.
|
|
174
|
+
*
|
|
175
|
+
* **Reserved.** `BojtosSession.deploy` takes a BPMN resource only, so there is
|
|
176
|
+
* currently no way to deploy a decision and nothing can populate this — treat
|
|
177
|
+
* a consumer that renders it as writing for a future engine, not reading live
|
|
178
|
+
* data. Kept in the contract so the shape doesn't change when deployment of
|
|
179
|
+
* decision resources lands.
|
|
180
|
+
*/
|
|
172
181
|
decisionInstances: DecisionInstanceDto[];
|
|
173
182
|
activeElementIds: string[];
|
|
174
183
|
incidentElementIds: string[];
|
package/dist/worker.d.ts
CHANGED
|
@@ -61,13 +61,72 @@ export interface DispatchOptions {
|
|
|
61
61
|
* whole agent conversation to quiescence.
|
|
62
62
|
*/
|
|
63
63
|
agents?: Record<string, AgentHandler>;
|
|
64
|
+
/**
|
|
65
|
+
* Let the drain loop move the virtual clock when it runs out of work but a
|
|
66
|
+
* timer is still pending: it jumps to the next due timer and keeps going.
|
|
67
|
+
*
|
|
68
|
+
* Off by default, because advancing time is a decision about what the demo is
|
|
69
|
+
* showing, not a detail. With it off, a model that waits on a timer settles
|
|
70
|
+
* with `reason: "timers"` — the loop is *done*, the process isn't — and the
|
|
71
|
+
* caller advances the clock itself.
|
|
72
|
+
*
|
|
73
|
+
* `true` advances as far as needed. `{ maxTotalMs }` sets a **budget for the
|
|
74
|
+
* whole drain**, not per jump — the loop advances while it can afford to and
|
|
75
|
+
* then settles with `reason: "timers"`, so "run for up to an hour of virtual
|
|
76
|
+
* time" is expressible and a `PT24H` timer can't be reached a second at a
|
|
77
|
+
* time.
|
|
78
|
+
*/
|
|
79
|
+
advanceTimers?: boolean | {
|
|
80
|
+
maxTotalMs: number;
|
|
81
|
+
};
|
|
64
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Why a drain stopped. `handled === 0` alone can't say: a completed instance, a
|
|
85
|
+
* human step, a pending timer, a message that never arrived and a job type
|
|
86
|
+
* nobody registered all look identical from the outside, and each one needs a
|
|
87
|
+
* different response from the UI above.
|
|
88
|
+
*/
|
|
89
|
+
export type SettleReason =
|
|
90
|
+
/** No live instances remain — every one completed or was terminated. */
|
|
91
|
+
"completed"
|
|
92
|
+
/** Waiting on a `userTask`; complete it with `session.completeUserTask`. */
|
|
93
|
+
| "userTasks"
|
|
94
|
+
/** Waiting on a timer; advance the clock (or pass `advanceTimers`). */
|
|
95
|
+
| "timers"
|
|
96
|
+
/** Waiting on a message subscription; publish with `correlateMessage`. */
|
|
97
|
+
| "messages"
|
|
98
|
+
/** Waiting on a signal subscription; publish with `broadcastSignal`. */
|
|
99
|
+
| "signals"
|
|
100
|
+
/** Jobs are waiting whose job type has no registered handler. */
|
|
101
|
+
| "unhandledJobs"
|
|
102
|
+
/** An incident is blocking progress; resolve it to continue. */
|
|
103
|
+
| "incidents"
|
|
104
|
+
/** Nothing is running and nothing is waiting — an empty or unstarted engine. */
|
|
105
|
+
| "idle";
|
|
106
|
+
/**
|
|
107
|
+
* Classify why the loop has nothing left to do. Pure, and exported so a consumer
|
|
108
|
+
* can label a snapshot it obtained some other way (and so it can be tested
|
|
109
|
+
* without an engine).
|
|
110
|
+
*
|
|
111
|
+
* Order matters: it reports the thing a caller can act on first. Incidents come
|
|
112
|
+
* before waiting states because an incident is why the wait will never end.
|
|
113
|
+
*/
|
|
114
|
+
export declare function settleReason(snapshot: Snapshot, handledJobTypes?: Iterable<string>): SettleReason;
|
|
115
|
+
/** Job types with waiting jobs that no registered handler serves. */
|
|
116
|
+
export declare function unhandledJobTypes(snapshot: Snapshot, handledJobTypes?: Iterable<string>): string[];
|
|
65
117
|
/** What one {@link dispatchRound} pass did. */
|
|
66
118
|
export interface RoundResult {
|
|
67
119
|
/** The snapshot after this pass. */
|
|
68
120
|
snapshot: Snapshot;
|
|
69
121
|
/** How many jobs were completed or failed in this pass. */
|
|
70
122
|
handled: number;
|
|
123
|
+
/**
|
|
124
|
+
* Why there was nothing left to do, when `handled === 0`. Undefined while the
|
|
125
|
+
* round did work — the loop hasn't settled, so there is nothing to explain.
|
|
126
|
+
*/
|
|
127
|
+
reason?: SettleReason;
|
|
128
|
+
/** Waiting job types no registered handler serves (usually a typo). */
|
|
129
|
+
unhandled?: string[];
|
|
71
130
|
}
|
|
72
131
|
/** What {@link dispatchWorkers} did. */
|
|
73
132
|
export interface DispatchResult {
|
|
@@ -77,6 +136,15 @@ export interface DispatchResult {
|
|
|
77
136
|
handled: number;
|
|
78
137
|
/** How many activate rounds ran (including the final quiescent one). */
|
|
79
138
|
rounds: number;
|
|
139
|
+
/**
|
|
140
|
+
* Why the drain stopped. Always set: a settled drain always has a reason, and
|
|
141
|
+
* "the loop finished" is not the same claim as "the process finished".
|
|
142
|
+
*/
|
|
143
|
+
reason: SettleReason;
|
|
144
|
+
/** Waiting job types no registered handler serves (usually a typo). */
|
|
145
|
+
unhandled: string[];
|
|
146
|
+
/** How far the virtual clock was moved, when `advanceTimers` is on. */
|
|
147
|
+
advancedMs: number;
|
|
80
148
|
}
|
|
81
149
|
/**
|
|
82
150
|
* Run one activate-and-handle pass: activate every registered job type's
|
package/dist/worker.js
CHANGED
|
@@ -12,6 +12,40 @@ export class JobFailure extends Error {
|
|
|
12
12
|
this.retries = opts?.retries;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Classify why the loop has nothing left to do. Pure, and exported so a consumer
|
|
17
|
+
* can label a snapshot it obtained some other way (and so it can be tested
|
|
18
|
+
* without an engine).
|
|
19
|
+
*
|
|
20
|
+
* Order matters: it reports the thing a caller can act on first. Incidents come
|
|
21
|
+
* before waiting states because an incident is why the wait will never end.
|
|
22
|
+
*/
|
|
23
|
+
export function settleReason(snapshot, handledJobTypes = []) {
|
|
24
|
+
const live = snapshot.instances.filter((i) => !i.completed);
|
|
25
|
+
if (live.length === 0)
|
|
26
|
+
return snapshot.totalInstances > 0 ? "completed" : "idle";
|
|
27
|
+
if (snapshot.incidents.length > 0)
|
|
28
|
+
return "incidents";
|
|
29
|
+
const known = new Set(handledJobTypes);
|
|
30
|
+
if (snapshot.jobs.some((j) => !known.has(j.jobType)))
|
|
31
|
+
return "unhandledJobs";
|
|
32
|
+
if (snapshot.userTasks.some((t) => t.state === "Created"))
|
|
33
|
+
return "userTasks";
|
|
34
|
+
if (snapshot.timers.length > 0)
|
|
35
|
+
return "timers";
|
|
36
|
+
if (snapshot.messageSubscriptions.length > 0)
|
|
37
|
+
return "messages";
|
|
38
|
+
if (snapshot.signalSubscriptions.length > 0)
|
|
39
|
+
return "signals";
|
|
40
|
+
return "idle";
|
|
41
|
+
}
|
|
42
|
+
/** Job types with waiting jobs that no registered handler serves. */
|
|
43
|
+
export function unhandledJobTypes(snapshot, handledJobTypes = []) {
|
|
44
|
+
const known = new Set(handledJobTypes);
|
|
45
|
+
return [...new Set(snapshot.jobs.map((j) => j.jobType))]
|
|
46
|
+
.filter((t) => !known.has(t))
|
|
47
|
+
.sort();
|
|
48
|
+
}
|
|
15
49
|
async function runOne(session, handler, job) {
|
|
16
50
|
let payload;
|
|
17
51
|
try {
|
|
@@ -112,9 +146,18 @@ export async function dispatchRound(session, workers, opts = {}) {
|
|
|
112
146
|
for (const { handler, job } of agentBatch) {
|
|
113
147
|
await runOneAgent(session, handler, job);
|
|
114
148
|
}
|
|
149
|
+
const snapshot = session.snapshot();
|
|
150
|
+
const handled = jobBatch.length + agentBatch.length;
|
|
151
|
+
if (handled > 0)
|
|
152
|
+
return { snapshot, handled };
|
|
153
|
+
// Nothing left to do this round — say why, so the caller isn't left to infer
|
|
154
|
+
// "finished" from "quiet".
|
|
155
|
+
const known = [...Object.keys(workers), ...Object.keys(agents)];
|
|
115
156
|
return {
|
|
116
|
-
snapshot
|
|
117
|
-
handled
|
|
157
|
+
snapshot,
|
|
158
|
+
handled,
|
|
159
|
+
reason: settleReason(snapshot, known),
|
|
160
|
+
unhandled: unhandledJobTypes(snapshot, known),
|
|
118
161
|
};
|
|
119
162
|
}
|
|
120
163
|
/**
|
|
@@ -129,8 +172,16 @@ export async function dispatchRound(session, workers, opts = {}) {
|
|
|
129
172
|
*/
|
|
130
173
|
export async function dispatchWorkers(session, workers, opts = {}) {
|
|
131
174
|
const maxRounds = opts.maxRounds ?? 1000;
|
|
175
|
+
// `typeof null === "object"`, so guard against a JS caller passing `null`
|
|
176
|
+
// (via `any`) — otherwise reading `.maxTotalMs` off it throws.
|
|
177
|
+
const advanceTimers = opts.advanceTimers;
|
|
178
|
+
const timeBudgetMs = advanceTimers != null && typeof advanceTimers === "object"
|
|
179
|
+
? advanceTimers.maxTotalMs
|
|
180
|
+
: Infinity;
|
|
181
|
+
const mayAdvance = advanceTimers != null && advanceTimers !== false;
|
|
132
182
|
let handled = 0;
|
|
133
183
|
let rounds = 0;
|
|
184
|
+
let advancedMs = 0;
|
|
134
185
|
for (;;) {
|
|
135
186
|
if (rounds >= maxRounds) {
|
|
136
187
|
throw new Error(`dispatchWorkers exceeded maxRounds (${maxRounds}) — a handler may be creating work without end`);
|
|
@@ -138,8 +189,40 @@ export async function dispatchWorkers(session, workers, opts = {}) {
|
|
|
138
189
|
rounds++;
|
|
139
190
|
const round = await dispatchRound(session, workers, opts);
|
|
140
191
|
handled += round.handled;
|
|
141
|
-
if (round.handled
|
|
142
|
-
|
|
192
|
+
if (round.handled > 0)
|
|
193
|
+
continue;
|
|
194
|
+
// Out of jobs. If the only thing standing between here and more work is the
|
|
195
|
+
// clock, and the caller asked us to, jump to the next due timer and carry
|
|
196
|
+
// on — otherwise a timer-bearing model looks finished when it is waiting.
|
|
197
|
+
if (mayAdvance && round.reason === "timers") {
|
|
198
|
+
const due = round.snapshot.timers.reduce((min, t) => Math.min(min, t.dueInMs), Infinity);
|
|
199
|
+
// `dueInMs` can be <= 0 for a timer that is already due but hasn't been
|
|
200
|
+
// triggered; nudge by 1ms so the clock always moves and the loop can't spin.
|
|
201
|
+
const jump = Math.max(due, 1);
|
|
202
|
+
// Only jump if the whole hop fits the budget. A partial hop would burn the
|
|
203
|
+
// budget without firing anything, which is strictly worse than stopping
|
|
204
|
+
// and telling the caller a timer is still pending.
|
|
205
|
+
if (Number.isFinite(jump) && advancedMs + jump <= timeBudgetMs) {
|
|
206
|
+
session.advanceTime(jump);
|
|
207
|
+
advancedMs += jump;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
143
210
|
}
|
|
211
|
+
return {
|
|
212
|
+
snapshot: round.snapshot,
|
|
213
|
+
handled,
|
|
214
|
+
rounds,
|
|
215
|
+
// `round.reason` is always set once `round.handled === 0` (the only way to
|
|
216
|
+
// reach here), so the fallback is currently unreachable — but if it ever
|
|
217
|
+
// did fire it must use the same handler set as the round, or it would
|
|
218
|
+
// recompute against an empty set and flag every job type as unhandled.
|
|
219
|
+
reason: round.reason ??
|
|
220
|
+
settleReason(round.snapshot, [
|
|
221
|
+
...Object.keys(workers),
|
|
222
|
+
...Object.keys(opts.agents ?? {}),
|
|
223
|
+
]),
|
|
224
|
+
unhandled: round.unhandled ?? [],
|
|
225
|
+
advancedMs,
|
|
226
|
+
};
|
|
144
227
|
}
|
|
145
228
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/bojtos-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ export {
|
|
|
11
11
|
export {
|
|
12
12
|
dispatchWorkers,
|
|
13
13
|
dispatchRound,
|
|
14
|
+
settleReason,
|
|
15
|
+
unhandledJobTypes,
|
|
14
16
|
JobFailure,
|
|
15
17
|
type JobHandler,
|
|
16
18
|
type JobResult,
|
|
@@ -18,7 +20,28 @@ export {
|
|
|
18
20
|
type DispatchOptions,
|
|
19
21
|
type DispatchResult,
|
|
20
22
|
type RoundResult,
|
|
23
|
+
type SettleReason,
|
|
21
24
|
} from "./worker.js";
|
|
25
|
+
// The shared trace model + both adapters (engine-event fold and handler-emitted
|
|
26
|
+
// `TraceEntry`) that retired the two forked `TraceTimeline` copies (#9). Pure and
|
|
27
|
+
// React-free — the presentational component lives in @nanobpm/bojtos-react.
|
|
28
|
+
export {
|
|
29
|
+
buildTraceItems,
|
|
30
|
+
isTraceTurnGroup,
|
|
31
|
+
foldEngineEvents,
|
|
32
|
+
traceEntriesToRows,
|
|
33
|
+
} from "./trace.js";
|
|
34
|
+
export type {
|
|
35
|
+
TraceRowKind,
|
|
36
|
+
TraceEntry,
|
|
37
|
+
TraceRow,
|
|
38
|
+
TraceTurnGroup,
|
|
39
|
+
TraceItem,
|
|
40
|
+
TraceAdapter,
|
|
41
|
+
} from "./trace.js";
|
|
42
|
+
// Every type reachable from `Snapshot` is exported: a consumer that can read
|
|
43
|
+
// `snapshot.userTasks` must also be able to name `UserTaskDto` to write a
|
|
44
|
+
// helper for it. Keep this list exhaustive when adding to `types.ts`.
|
|
22
45
|
export type {
|
|
23
46
|
Snapshot,
|
|
24
47
|
InstanceDto,
|
|
@@ -26,7 +49,14 @@ export type {
|
|
|
26
49
|
ActivatedJob,
|
|
27
50
|
IncidentDto,
|
|
28
51
|
TimerDto,
|
|
52
|
+
UserTaskDto,
|
|
53
|
+
MessageSubscriptionDto,
|
|
54
|
+
SignalSubscriptionDto,
|
|
55
|
+
ElementStatDto,
|
|
56
|
+
SequenceFlowDto,
|
|
57
|
+
DecisionInstanceDto,
|
|
29
58
|
ActiveEl,
|
|
59
|
+
ActivateInstruction,
|
|
30
60
|
AgentActivation,
|
|
31
61
|
AgentResult,
|
|
32
62
|
WasmEvent,
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// The framework-agnostic trace model shared by the Bojtos demo framework and the
|
|
2
|
+
// console test-view — the single source that retired the two drifted, forked
|
|
3
|
+
// `TraceTimeline` copies (nanobpm/bojtos#9). It defines one normalized row/turn
|
|
4
|
+
// model plus the two adapters that map a source into it:
|
|
5
|
+
//
|
|
6
|
+
// 1. the **engine-event fold** — `WasmEvent[]` (from `useBojtos().events`) →
|
|
7
|
+
// rows, covering the non-agentic / test-view case; and
|
|
8
|
+
// 2. the **handler-emitted `TraceEntry`** adapter — the agent/tool/turn entries
|
|
9
|
+
// (with the additive `turn` grouping field) → rows, covering the agentic
|
|
10
|
+
// web-demo case.
|
|
11
|
+
//
|
|
12
|
+
// It is deliberately React-free: the presentational component (`TraceTimeline` in
|
|
13
|
+
// `@nanobpm/bojtos-react`) renders this model, keeping the view layer thin.
|
|
14
|
+
|
|
15
|
+
import type { WasmEvent } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Semantic classification of a trace row, driving how the view styles it (the
|
|
19
|
+
* `log-<kind>` class the two forked timelines already keyed off) and which
|
|
20
|
+
* affordance it carries. Framework-agnostic — an engine-event fold and an
|
|
21
|
+
* agentic handler stream both land in this shared vocabulary:
|
|
22
|
+
*
|
|
23
|
+
* - `start` — the run/instance began.
|
|
24
|
+
* - `agent` — an agent decision, or a tool the agent activated this turn.
|
|
25
|
+
* - `llm` — a raw model reply.
|
|
26
|
+
* - `tool` — a tool/handler log line (a job running, a timer, a message).
|
|
27
|
+
* - `human` — a user task awaiting or completed by a person.
|
|
28
|
+
* - `done` — the final outcome (the instance completed/terminated).
|
|
29
|
+
* - `error` — a failure, incident, or thrown error.
|
|
30
|
+
* - `vars` — a variables/result update (e.g. what a tool returned).
|
|
31
|
+
*/
|
|
32
|
+
export type TraceRowKind =
|
|
33
|
+
| "start"
|
|
34
|
+
| "agent"
|
|
35
|
+
| "llm"
|
|
36
|
+
| "tool"
|
|
37
|
+
| "human"
|
|
38
|
+
| "done"
|
|
39
|
+
| "error"
|
|
40
|
+
| "vars";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A source line before it is placed in the normalized model. This is the shape a
|
|
44
|
+
* handler emits (the web-demo framework's `TraceEntry`): `kind`/`text` are all a
|
|
45
|
+
* plain consumer needs; every other field is additive and safe to ignore.
|
|
46
|
+
*/
|
|
47
|
+
export interface TraceEntry {
|
|
48
|
+
kind: TraceRowKind;
|
|
49
|
+
/** The human-readable line. */
|
|
50
|
+
text: string;
|
|
51
|
+
/**
|
|
52
|
+
* Stable id for an entry that updates in place — a streaming completion grows
|
|
53
|
+
* one line rather than spamming forty.
|
|
54
|
+
*/
|
|
55
|
+
key?: string;
|
|
56
|
+
/** True while the entry is still being produced (renders a spinner). */
|
|
57
|
+
pending?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Groups every entry produced by one agent turn together (the streamed LLM
|
|
60
|
+
* reply, each tool it activated, and that tool's result). Consecutive entries
|
|
61
|
+
* sharing a `turn` fold into one turn card; entries with no `turn` render as
|
|
62
|
+
* plain rows in their original order.
|
|
63
|
+
*/
|
|
64
|
+
turn?: number;
|
|
65
|
+
/** The BPMN element (tool or task) this entry concerns. */
|
|
66
|
+
elementId?: string;
|
|
67
|
+
/** Arguments supplied when activating a tool — the coerced values, not the raw reply. */
|
|
68
|
+
args?: Record<string, unknown>;
|
|
69
|
+
/** What a tool/handler returned, paired with its activation by `elementId`. */
|
|
70
|
+
result?: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A normalized row: a {@link TraceEntry} stamped with a stable, monotonic `id`.
|
|
75
|
+
* The `id` is what the view keys off and what pairs a tool's result with its
|
|
76
|
+
* activation and orders loose lines within a turn.
|
|
77
|
+
*/
|
|
78
|
+
export interface TraceRow extends TraceEntry {
|
|
79
|
+
id: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Consecutive same-turn rows, folded into one group by {@link buildTraceItems}. */
|
|
83
|
+
export interface TraceTurnGroup {
|
|
84
|
+
turn: number;
|
|
85
|
+
rows: TraceRow[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A top-level item the view renders: either a plain row or a turn group. */
|
|
89
|
+
export type TraceItem = TraceRow | TraceTurnGroup;
|
|
90
|
+
|
|
91
|
+
/** Narrow a {@link TraceItem} to a {@link TraceTurnGroup}. */
|
|
92
|
+
export function isTraceTurnGroup(item: TraceItem): item is TraceTurnGroup {
|
|
93
|
+
return (item as TraceTurnGroup).rows !== undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* An adapter maps a source (`WasmEvent[]`, a `TraceEntry[]`, …) into the shared
|
|
98
|
+
* normalized row model. Both built-in adapters — {@link foldEngineEvents} and
|
|
99
|
+
* {@link traceEntriesToRows} — satisfy this; a consumer can supply its own for a
|
|
100
|
+
* bespoke source.
|
|
101
|
+
*/
|
|
102
|
+
export type TraceAdapter<TSource> = (source: TSource) => TraceRow[];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fold a flat list of normalized rows into the view model: consecutive rows
|
|
106
|
+
* sharing a `turn` become one {@link TraceTurnGroup}; everything else stays a
|
|
107
|
+
* plain row in its original order. A row with no `turn` breaks the current group,
|
|
108
|
+
* so an interleaved non-turn line never gets swallowed into a card. This is the
|
|
109
|
+
* grouping both forked timelines did by hand, lifted into the shared kit.
|
|
110
|
+
*/
|
|
111
|
+
export function buildTraceItems(rows: TraceRow[]): TraceItem[] {
|
|
112
|
+
const items: TraceItem[] = [];
|
|
113
|
+
let current: TraceTurnGroup | null = null;
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
if (row.turn !== undefined) {
|
|
116
|
+
if (current && current.turn === row.turn) {
|
|
117
|
+
current.rows.push(row);
|
|
118
|
+
} else {
|
|
119
|
+
current = { turn: row.turn, rows: [row] };
|
|
120
|
+
items.push(current);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
current = null;
|
|
124
|
+
items.push(row);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return items;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The handler-emitted `TraceEntry` adapter: stamp each entry with a stable `id`
|
|
132
|
+
* (its index) to lift it into a {@link TraceRow}. The entries already carry the
|
|
133
|
+
* additive `turn`/`elementId`/`args`/`result` fields, so {@link buildTraceItems}
|
|
134
|
+
* over the result reproduces the agentic turn-grouped card view without any
|
|
135
|
+
* re-forked grouping logic. The input is never mutated.
|
|
136
|
+
*/
|
|
137
|
+
export function traceEntriesToRows(entries: readonly TraceEntry[]): TraceRow[] {
|
|
138
|
+
return entries.map((entry, id) => ({ ...entry, id }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* How one engine event type folds into the trace: its row {@link TraceRowKind}
|
|
143
|
+
* and a function turning the event's snake_case payload into a line. Returning
|
|
144
|
+
* a mapping opts the event into the story; every type absent from the table
|
|
145
|
+
* below is deliberately dropped as low-signal lifecycle noise (`ElementActivating`
|
|
146
|
+
* / `ElementCompleting`, `JobActivated`, `SequenceFlowTaken`, the scoped-variable
|
|
147
|
+
* and parallel-join bookkeeping), so the fold reads as a run's milestones rather
|
|
148
|
+
* than a raw trace.
|
|
149
|
+
*/
|
|
150
|
+
interface EngineEventRule {
|
|
151
|
+
kind: TraceRowKind;
|
|
152
|
+
text: (ev: WasmEvent) => string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Read a string field off a `WasmEvent`'s open payload, or `undefined`. */
|
|
156
|
+
function str(ev: WasmEvent, key: string): string | undefined {
|
|
157
|
+
const v = ev[key];
|
|
158
|
+
return typeof v === "string" ? v : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The element this event concerns, if it names one — for pairing/labelling. */
|
|
162
|
+
function elementOf(ev: WasmEvent): string | undefined {
|
|
163
|
+
return str(ev, "element_id");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ENGINE_EVENT_RULES: Record<string, EngineEventRule> = {
|
|
167
|
+
ProcessInstanceCreated: {
|
|
168
|
+
kind: "start",
|
|
169
|
+
text: (ev) => `Process ${str(ev, "process_id") ?? "instance"} started`,
|
|
170
|
+
},
|
|
171
|
+
JobCreated: {
|
|
172
|
+
kind: "tool",
|
|
173
|
+
text: (ev) =>
|
|
174
|
+
`Job ${str(ev, "job_type") ?? ""} created`.trim() +
|
|
175
|
+
(elementOf(ev) ? ` on ${elementOf(ev)}` : ""),
|
|
176
|
+
},
|
|
177
|
+
JobCompleted: {
|
|
178
|
+
kind: "vars",
|
|
179
|
+
text: (ev) => `Job ${str(ev, "job_type") ?? ""} completed`.trim(),
|
|
180
|
+
},
|
|
181
|
+
JobFailed: {
|
|
182
|
+
kind: "error",
|
|
183
|
+
text: (ev) => `Job ${str(ev, "job_type") ?? ""} failed`.trim(),
|
|
184
|
+
},
|
|
185
|
+
JobErrorThrown: {
|
|
186
|
+
kind: "error",
|
|
187
|
+
text: (ev) =>
|
|
188
|
+
`Job threw error ${str(ev, "error_code") ?? ""}`.trim() +
|
|
189
|
+
(elementOf(ev) ? ` on ${elementOf(ev)}` : ""),
|
|
190
|
+
},
|
|
191
|
+
IncidentRaised: {
|
|
192
|
+
kind: "error",
|
|
193
|
+
text: (ev) =>
|
|
194
|
+
`Incident on ${elementOf(ev) ?? "instance"}` +
|
|
195
|
+
(str(ev, "reason") ? `: ${str(ev, "reason")}` : ""),
|
|
196
|
+
},
|
|
197
|
+
IncidentResolved: {
|
|
198
|
+
kind: "tool",
|
|
199
|
+
text: (ev) => `Incident resolved on ${elementOf(ev) ?? "instance"}`,
|
|
200
|
+
},
|
|
201
|
+
UserTaskCreated: {
|
|
202
|
+
kind: "human",
|
|
203
|
+
text: (ev) => `User task ${elementOf(ev) ?? ""} awaiting a human`.trim(),
|
|
204
|
+
},
|
|
205
|
+
UserTaskAssigned: {
|
|
206
|
+
kind: "human",
|
|
207
|
+
text: (ev) =>
|
|
208
|
+
`User task ${elementOf(ev) ?? ""} assigned`.trim() +
|
|
209
|
+
(str(ev, "assignee") ? ` to ${str(ev, "assignee")}` : ""),
|
|
210
|
+
},
|
|
211
|
+
UserTaskCompleted: {
|
|
212
|
+
kind: "human",
|
|
213
|
+
text: (ev) => `User task ${elementOf(ev) ?? ""} completed`.trim(),
|
|
214
|
+
},
|
|
215
|
+
UserTaskCanceled: {
|
|
216
|
+
kind: "human",
|
|
217
|
+
text: (ev) => `User task ${elementOf(ev) ?? ""} canceled`.trim(),
|
|
218
|
+
},
|
|
219
|
+
TimerCreated: {
|
|
220
|
+
kind: "tool",
|
|
221
|
+
text: (ev) => `Timer set on ${elementOf(ev) ?? "instance"}`,
|
|
222
|
+
},
|
|
223
|
+
TimerTriggered: {
|
|
224
|
+
kind: "tool",
|
|
225
|
+
text: (ev) => `Timer fired on ${elementOf(ev) ?? "instance"}`,
|
|
226
|
+
},
|
|
227
|
+
MessagePublished: {
|
|
228
|
+
kind: "tool",
|
|
229
|
+
text: (ev) => `Message ${str(ev, "message_name") ?? ""} published`.trim(),
|
|
230
|
+
},
|
|
231
|
+
MessageCorrelated: {
|
|
232
|
+
kind: "tool",
|
|
233
|
+
text: (ev) => `Message ${str(ev, "message_name") ?? ""} correlated`.trim(),
|
|
234
|
+
},
|
|
235
|
+
SignalBroadcast: {
|
|
236
|
+
kind: "tool",
|
|
237
|
+
text: (ev) => `Signal ${str(ev, "signal_name") ?? ""} broadcast`.trim(),
|
|
238
|
+
},
|
|
239
|
+
SignalCorrelated: {
|
|
240
|
+
kind: "tool",
|
|
241
|
+
text: (ev) => `Signal ${str(ev, "signal_name") ?? ""} correlated`.trim(),
|
|
242
|
+
},
|
|
243
|
+
AdHocActivated: {
|
|
244
|
+
kind: "agent",
|
|
245
|
+
text: (ev) => `Ad-hoc sub-process ${elementOf(ev) ?? ""} activated`.trim(),
|
|
246
|
+
},
|
|
247
|
+
AdHocToolActivated: {
|
|
248
|
+
kind: "agent",
|
|
249
|
+
text: (ev) => `Tool ${elementOf(ev) ?? ""} activated`.trim(),
|
|
250
|
+
},
|
|
251
|
+
AdHocToolCompleted: {
|
|
252
|
+
kind: "vars",
|
|
253
|
+
text: (ev) => `Tool ${elementOf(ev) ?? ""} returned`.trim(),
|
|
254
|
+
},
|
|
255
|
+
AdHocCompleted: {
|
|
256
|
+
kind: "agent",
|
|
257
|
+
text: (ev) => `Ad-hoc sub-process ${elementOf(ev) ?? ""} completed`.trim(),
|
|
258
|
+
},
|
|
259
|
+
ProcessInstanceCompleted: {
|
|
260
|
+
kind: "done",
|
|
261
|
+
text: () => "Process completed",
|
|
262
|
+
},
|
|
263
|
+
ProcessInstanceTerminated: {
|
|
264
|
+
kind: "error",
|
|
265
|
+
text: () => "Process terminated",
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The engine-event fold adapter: map a `WasmEvent[]` (the flattened
|
|
271
|
+
* `{ seq, now, type, …snake_case }` stream from `useBojtos().events`) into
|
|
272
|
+
* normalized rows, keeping only the meaningful milestones (see
|
|
273
|
+
* {@link ENGINE_EVENT_RULES}). Each row's `id` is the event's `seq`, so ids stay
|
|
274
|
+
* stable and monotonic across re-reads of a growing log. Engine events carry no
|
|
275
|
+
* turn, so {@link buildTraceItems} over the result is a flat list of rows — the
|
|
276
|
+
* non-agentic test-view shape.
|
|
277
|
+
*/
|
|
278
|
+
export function foldEngineEvents(events: readonly WasmEvent[]): TraceRow[] {
|
|
279
|
+
const rows: TraceRow[] = [];
|
|
280
|
+
for (const ev of events) {
|
|
281
|
+
const rule = ENGINE_EVENT_RULES[ev.type];
|
|
282
|
+
if (!rule) continue;
|
|
283
|
+
rows.push({
|
|
284
|
+
id: ev.seq,
|
|
285
|
+
kind: rule.kind,
|
|
286
|
+
text: rule.text(ev),
|
|
287
|
+
elementId: elementOf(ev),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -190,6 +190,15 @@ export interface Snapshot {
|
|
|
190
190
|
signalSubscriptions: SignalSubscriptionDto[];
|
|
191
191
|
elementStats: ElementStatDto[];
|
|
192
192
|
takenSequenceFlows: SequenceFlowDto[];
|
|
193
|
+
/**
|
|
194
|
+
* Evaluated DMN decisions.
|
|
195
|
+
*
|
|
196
|
+
* **Reserved.** `BojtosSession.deploy` takes a BPMN resource only, so there is
|
|
197
|
+
* currently no way to deploy a decision and nothing can populate this — treat
|
|
198
|
+
* a consumer that renders it as writing for a future engine, not reading live
|
|
199
|
+
* data. Kept in the contract so the shape doesn't change when deployment of
|
|
200
|
+
* decision resources lands.
|
|
201
|
+
*/
|
|
193
202
|
decisionInstances: DecisionInstanceDto[];
|
|
194
203
|
activeElementIds: string[];
|
|
195
204
|
incidentElementIds: string[];
|
package/src/worker.ts
CHANGED
|
@@ -72,6 +72,84 @@ export interface DispatchOptions {
|
|
|
72
72
|
* whole agent conversation to quiescence.
|
|
73
73
|
*/
|
|
74
74
|
agents?: Record<string, AgentHandler>;
|
|
75
|
+
/**
|
|
76
|
+
* Let the drain loop move the virtual clock when it runs out of work but a
|
|
77
|
+
* timer is still pending: it jumps to the next due timer and keeps going.
|
|
78
|
+
*
|
|
79
|
+
* Off by default, because advancing time is a decision about what the demo is
|
|
80
|
+
* showing, not a detail. With it off, a model that waits on a timer settles
|
|
81
|
+
* with `reason: "timers"` — the loop is *done*, the process isn't — and the
|
|
82
|
+
* caller advances the clock itself.
|
|
83
|
+
*
|
|
84
|
+
* `true` advances as far as needed. `{ maxTotalMs }` sets a **budget for the
|
|
85
|
+
* whole drain**, not per jump — the loop advances while it can afford to and
|
|
86
|
+
* then settles with `reason: "timers"`, so "run for up to an hour of virtual
|
|
87
|
+
* time" is expressible and a `PT24H` timer can't be reached a second at a
|
|
88
|
+
* time.
|
|
89
|
+
*/
|
|
90
|
+
advanceTimers?: boolean | { maxTotalMs: number };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Why a drain stopped. `handled === 0` alone can't say: a completed instance, a
|
|
95
|
+
* human step, a pending timer, a message that never arrived and a job type
|
|
96
|
+
* nobody registered all look identical from the outside, and each one needs a
|
|
97
|
+
* different response from the UI above.
|
|
98
|
+
*/
|
|
99
|
+
export type SettleReason =
|
|
100
|
+
/** No live instances remain — every one completed or was terminated. */
|
|
101
|
+
| "completed"
|
|
102
|
+
/** Waiting on a `userTask`; complete it with `session.completeUserTask`. */
|
|
103
|
+
| "userTasks"
|
|
104
|
+
/** Waiting on a timer; advance the clock (or pass `advanceTimers`). */
|
|
105
|
+
| "timers"
|
|
106
|
+
/** Waiting on a message subscription; publish with `correlateMessage`. */
|
|
107
|
+
| "messages"
|
|
108
|
+
/** Waiting on a signal subscription; publish with `broadcastSignal`. */
|
|
109
|
+
| "signals"
|
|
110
|
+
/** Jobs are waiting whose job type has no registered handler. */
|
|
111
|
+
| "unhandledJobs"
|
|
112
|
+
/** An incident is blocking progress; resolve it to continue. */
|
|
113
|
+
| "incidents"
|
|
114
|
+
/** Nothing is running and nothing is waiting — an empty or unstarted engine. */
|
|
115
|
+
| "idle";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Classify why the loop has nothing left to do. Pure, and exported so a consumer
|
|
119
|
+
* can label a snapshot it obtained some other way (and so it can be tested
|
|
120
|
+
* without an engine).
|
|
121
|
+
*
|
|
122
|
+
* Order matters: it reports the thing a caller can act on first. Incidents come
|
|
123
|
+
* before waiting states because an incident is why the wait will never end.
|
|
124
|
+
*/
|
|
125
|
+
export function settleReason(
|
|
126
|
+
snapshot: Snapshot,
|
|
127
|
+
handledJobTypes: Iterable<string> = [],
|
|
128
|
+
): SettleReason {
|
|
129
|
+
const live = snapshot.instances.filter((i) => !i.completed);
|
|
130
|
+
if (live.length === 0)
|
|
131
|
+
return snapshot.totalInstances > 0 ? "completed" : "idle";
|
|
132
|
+
if (snapshot.incidents.length > 0) return "incidents";
|
|
133
|
+
|
|
134
|
+
const known = new Set(handledJobTypes);
|
|
135
|
+
if (snapshot.jobs.some((j) => !known.has(j.jobType))) return "unhandledJobs";
|
|
136
|
+
|
|
137
|
+
if (snapshot.userTasks.some((t) => t.state === "Created")) return "userTasks";
|
|
138
|
+
if (snapshot.timers.length > 0) return "timers";
|
|
139
|
+
if (snapshot.messageSubscriptions.length > 0) return "messages";
|
|
140
|
+
if (snapshot.signalSubscriptions.length > 0) return "signals";
|
|
141
|
+
return "idle";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Job types with waiting jobs that no registered handler serves. */
|
|
145
|
+
export function unhandledJobTypes(
|
|
146
|
+
snapshot: Snapshot,
|
|
147
|
+
handledJobTypes: Iterable<string> = [],
|
|
148
|
+
): string[] {
|
|
149
|
+
const known = new Set(handledJobTypes);
|
|
150
|
+
return [...new Set(snapshot.jobs.map((j) => j.jobType))]
|
|
151
|
+
.filter((t) => !known.has(t))
|
|
152
|
+
.sort();
|
|
75
153
|
}
|
|
76
154
|
|
|
77
155
|
/** What one {@link dispatchRound} pass did. */
|
|
@@ -80,6 +158,13 @@ export interface RoundResult {
|
|
|
80
158
|
snapshot: Snapshot;
|
|
81
159
|
/** How many jobs were completed or failed in this pass. */
|
|
82
160
|
handled: number;
|
|
161
|
+
/**
|
|
162
|
+
* Why there was nothing left to do, when `handled === 0`. Undefined while the
|
|
163
|
+
* round did work — the loop hasn't settled, so there is nothing to explain.
|
|
164
|
+
*/
|
|
165
|
+
reason?: SettleReason;
|
|
166
|
+
/** Waiting job types no registered handler serves (usually a typo). */
|
|
167
|
+
unhandled?: string[];
|
|
83
168
|
}
|
|
84
169
|
|
|
85
170
|
/** What {@link dispatchWorkers} did. */
|
|
@@ -90,6 +175,15 @@ export interface DispatchResult {
|
|
|
90
175
|
handled: number;
|
|
91
176
|
/** How many activate rounds ran (including the final quiescent one). */
|
|
92
177
|
rounds: number;
|
|
178
|
+
/**
|
|
179
|
+
* Why the drain stopped. Always set: a settled drain always has a reason, and
|
|
180
|
+
* "the loop finished" is not the same claim as "the process finished".
|
|
181
|
+
*/
|
|
182
|
+
reason: SettleReason;
|
|
183
|
+
/** Waiting job types no registered handler serves (usually a typo). */
|
|
184
|
+
unhandled: string[];
|
|
185
|
+
/** How far the virtual clock was moved, when `advanceTimers` is on. */
|
|
186
|
+
advancedMs: number;
|
|
93
187
|
}
|
|
94
188
|
|
|
95
189
|
async function runOne(
|
|
@@ -208,9 +302,17 @@ export async function dispatchRound(
|
|
|
208
302
|
for (const { handler, job } of agentBatch) {
|
|
209
303
|
await runOneAgent(session, handler, job);
|
|
210
304
|
}
|
|
305
|
+
const snapshot = session.snapshot();
|
|
306
|
+
const handled = jobBatch.length + agentBatch.length;
|
|
307
|
+
if (handled > 0) return { snapshot, handled };
|
|
308
|
+
// Nothing left to do this round — say why, so the caller isn't left to infer
|
|
309
|
+
// "finished" from "quiet".
|
|
310
|
+
const known = [...Object.keys(workers), ...Object.keys(agents)];
|
|
211
311
|
return {
|
|
212
|
-
snapshot
|
|
213
|
-
handled
|
|
312
|
+
snapshot,
|
|
313
|
+
handled,
|
|
314
|
+
reason: settleReason(snapshot, known),
|
|
315
|
+
unhandled: unhandledJobTypes(snapshot, known),
|
|
214
316
|
};
|
|
215
317
|
}
|
|
216
318
|
|
|
@@ -230,8 +332,18 @@ export async function dispatchWorkers(
|
|
|
230
332
|
opts: DispatchOptions = {},
|
|
231
333
|
): Promise<DispatchResult> {
|
|
232
334
|
const maxRounds = opts.maxRounds ?? 1000;
|
|
335
|
+
// `typeof null === "object"`, so guard against a JS caller passing `null`
|
|
336
|
+
// (via `any`) — otherwise reading `.maxTotalMs` off it throws.
|
|
337
|
+
const advanceTimers = opts.advanceTimers;
|
|
338
|
+
const timeBudgetMs =
|
|
339
|
+
advanceTimers != null && typeof advanceTimers === "object"
|
|
340
|
+
? advanceTimers.maxTotalMs
|
|
341
|
+
: Infinity;
|
|
342
|
+
const mayAdvance = advanceTimers != null && advanceTimers !== false;
|
|
343
|
+
|
|
233
344
|
let handled = 0;
|
|
234
345
|
let rounds = 0;
|
|
346
|
+
let advancedMs = 0;
|
|
235
347
|
for (;;) {
|
|
236
348
|
if (rounds >= maxRounds) {
|
|
237
349
|
throw new Error(
|
|
@@ -241,8 +353,45 @@ export async function dispatchWorkers(
|
|
|
241
353
|
rounds++;
|
|
242
354
|
const round = await dispatchRound(session, workers, opts);
|
|
243
355
|
handled += round.handled;
|
|
244
|
-
if (round.handled
|
|
245
|
-
|
|
356
|
+
if (round.handled > 0) continue;
|
|
357
|
+
|
|
358
|
+
// Out of jobs. If the only thing standing between here and more work is the
|
|
359
|
+
// clock, and the caller asked us to, jump to the next due timer and carry
|
|
360
|
+
// on — otherwise a timer-bearing model looks finished when it is waiting.
|
|
361
|
+
if (mayAdvance && round.reason === "timers") {
|
|
362
|
+
const due = round.snapshot.timers.reduce(
|
|
363
|
+
(min, t) => Math.min(min, t.dueInMs),
|
|
364
|
+
Infinity,
|
|
365
|
+
);
|
|
366
|
+
// `dueInMs` can be <= 0 for a timer that is already due but hasn't been
|
|
367
|
+
// triggered; nudge by 1ms so the clock always moves and the loop can't spin.
|
|
368
|
+
const jump = Math.max(due, 1);
|
|
369
|
+
// Only jump if the whole hop fits the budget. A partial hop would burn the
|
|
370
|
+
// budget without firing anything, which is strictly worse than stopping
|
|
371
|
+
// and telling the caller a timer is still pending.
|
|
372
|
+
if (Number.isFinite(jump) && advancedMs + jump <= timeBudgetMs) {
|
|
373
|
+
session.advanceTime(jump);
|
|
374
|
+
advancedMs += jump;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
246
377
|
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
snapshot: round.snapshot,
|
|
381
|
+
handled,
|
|
382
|
+
rounds,
|
|
383
|
+
// `round.reason` is always set once `round.handled === 0` (the only way to
|
|
384
|
+
// reach here), so the fallback is currently unreachable — but if it ever
|
|
385
|
+
// did fire it must use the same handler set as the round, or it would
|
|
386
|
+
// recompute against an empty set and flag every job type as unhandled.
|
|
387
|
+
reason:
|
|
388
|
+
round.reason ??
|
|
389
|
+
settleReason(round.snapshot, [
|
|
390
|
+
...Object.keys(workers),
|
|
391
|
+
...Object.keys(opts.agents ?? {}),
|
|
392
|
+
]),
|
|
393
|
+
unhandled: round.unhandled ?? [],
|
|
394
|
+
advancedMs,
|
|
395
|
+
};
|
|
247
396
|
}
|
|
248
397
|
}
|