@nanobpm/bojtos-react 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 CHANGED
@@ -10,6 +10,15 @@ React binding for the **Bojtos** in-browser BPMN demo framework
10
10
  - **`<BpmnRuntimeView xml activeIds incidentIds />`** — the live diagram: it
11
11
  imports the XML once and updates token (`nano-active`) / incident
12
12
  (`nano-incident`) markers in place, so zoom/scroll survive stepping.
13
+ - **`<TraceTimeline rows />`** — the shared activity log: it renders the
14
+ framework-agnostic trace model from [`@nanobpm/bojtos-kit`](../bojtos-kit) as a
15
+ turn-by-turn story (consecutive same-`turn` rows fold into one card; rows with
16
+ no `turn` render as plain lines). Feed it a kit adapter —
17
+ `foldEngineEvents(run.events)` for a plain engine run, or
18
+ `traceEntriesToRows(entries)` for handler-emitted agent/tool/turn entries. It
19
+ imports **only** the kit and React, so a trace-only import tree-shakes `bpmn-js`
20
+ out (the package is `sideEffects: false`); a test walks the built module graph
21
+ to pin that.
13
22
 
14
23
  ## Install
15
24
 
@@ -42,6 +51,26 @@ function Demo({ bpmn }: { bpmn: string }) {
42
51
  }
43
52
  ```
44
53
 
54
+ ## Trace timeline
55
+
56
+ ```tsx
57
+ import { useBojtos, TraceTimeline, foldEngineEvents } from "@nanobpm/bojtos-react";
58
+
59
+ function RunLog({ bpmn }: { bpmn: string }) {
60
+ const run = useBojtos({ bpmn });
61
+ // Engine-event fold — the non-agentic / test-view case.
62
+ return <TraceTimeline rows={foldEngineEvents(run.events)} />;
63
+ }
64
+ ```
65
+
66
+ For an agentic run, emit `TraceEntry` lines from your handlers (with the additive
67
+ `turn` / `elementId` / `args` / `result` fields) and pass
68
+ `traceEntriesToRows(entries)` instead — same component, turn-grouped card view.
69
+ `TraceTimeline` keeps the class names (`timeline`, `timeline-turn`,
70
+ `log-line log-<kind>`, …) the demo stylesheet already targets, so your CSS applies
71
+ unchanged. It never imports `bpmn-js`, so importing it alone won't pull the
72
+ diagram bundle in.
73
+
45
74
  ## Peer requirements
46
75
 
47
76
  `react` and `bpmn-js` are peer dependencies (the consumer already has them). The
@@ -51,6 +80,7 @@ consumer must import bpmn-js's diagram CSS once and provide the `.nano-active` /
51
80
  ## Build
52
81
 
53
82
  `dist/` (the tsc-emitted JS + `.d.ts`, with JSX already compiled to
54
- `react/jsx-runtime` so consumers never re-transform node_modules) is committed
55
- so `file:` consumers and CI need no build-on-install step. Regenerate with
83
+ `react/jsx-runtime` so consumers never re-transform node_modules) is what ships,
84
+ built by `prepack` on publish. It is **not** committed `.gitignore` covers it —
85
+ so build before pointing a `file:` consumer at this workspace. Regenerate with
56
86
  `npm run build`.
@@ -7,6 +7,17 @@ export interface BpmnRuntimeViewProps {
7
7
  incidentIds: string[];
8
8
  /** Optional class for the container element (it always fills its parent). */
9
9
  className?: string;
10
+ /**
11
+ * Accessible name for the diagram. The token and incident highlights are
12
+ * purely visual, so without this a screen-reader user is told nothing at all
13
+ * about what is running.
14
+ */
15
+ label?: string;
16
+ /**
17
+ * Map an element id to a human name for the live status announcement — pass
18
+ * the diagram's element names if you have them. Defaults to the raw id.
19
+ */
20
+ elementName?: (elementId: string) => string;
10
21
  }
11
22
  /**
12
23
  * Read-only diagram that imports the XML once and updates token/incident markers
@@ -20,4 +31,4 @@ export interface BpmnRuntimeViewProps {
20
31
  * and provide the `.nano-active` / `.nano-incident` marker styles plus a
21
32
  * `.nano-token` style for the token badge overlaid on each active element.
22
33
  */
23
- export declare function BpmnRuntimeView({ xml, activeIds, incidentIds, className, }: BpmnRuntimeViewProps): import("react").JSX.Element;
34
+ export declare function BpmnRuntimeView({ xml, activeIds, incidentIds, className, label, elementName, }: BpmnRuntimeViewProps): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
3
  import NavigatedViewer from "bpmn-js/lib/NavigatedViewer";
4
+ import { describeRunState, markerKey } from "./runState.js";
4
5
  /**
5
6
  * Read-only diagram that imports the XML once and updates token/incident markers
6
7
  * in place (no re-import, so the zoom/scroll position is preserved while
@@ -13,7 +14,7 @@ import NavigatedViewer from "bpmn-js/lib/NavigatedViewer";
13
14
  * and provide the `.nano-active` / `.nano-incident` marker styles plus a
14
15
  * `.nano-token` style for the token badge overlaid on each active element.
15
16
  */
16
- export function BpmnRuntimeView({ xml, activeIds, incidentIds, className, }) {
17
+ export function BpmnRuntimeView({ xml, activeIds, incidentIds, className, label = "BPMN process diagram", elementName, }) {
17
18
  const containerRef = useRef(null);
18
19
  const viewerRef = useRef(null);
19
20
  const importedRef = useRef(false);
@@ -101,9 +102,24 @@ export function BpmnRuntimeView({ xml, activeIds, incidentIds, className, }) {
101
102
  }
102
103
  tokenOverlaysRef.current = nextOverlays;
103
104
  }
105
+ // `activeIds` / `incidentIds` are almost always fresh arrays (`snapshot?.x ??
106
+ // []`), so depending on their identity re-painted every marker and re-created
107
+ // every token overlay on every render of the parent — visible churn on a busy
108
+ // diagram. Depend on the ids themselves instead.
109
+ const key = markerKey(activeIds, incidentIds);
104
110
  useEffect(() => {
105
111
  applyMarkers();
106
112
  // eslint-disable-next-line react-hooks/exhaustive-deps
107
- }, [activeIds, incidentIds]);
108
- return (_jsx("div", { ref: containerRef, className: className, style: { width: "100%", height: "100%" } }));
113
+ }, [key]);
114
+ return (_jsxs("div", { className: className, style: { width: "100%", height: "100%", position: "relative" }, children: [_jsx("div", { ref: containerRef, role: "img", "aria-label": label, style: { width: "100%", height: "100%" } }), _jsx("div", { role: "status", "aria-live": "polite", style: {
115
+ position: "absolute",
116
+ width: 1,
117
+ height: 1,
118
+ margin: -1,
119
+ padding: 0,
120
+ overflow: "hidden",
121
+ clip: "rect(0 0 0 0)",
122
+ whiteSpace: "nowrap",
123
+ border: 0,
124
+ }, children: describeRunState(activeIds, incidentIds, elementName) })] }));
109
125
  }
@@ -0,0 +1,50 @@
1
+ import { type ElementStatDto, type IncidentDto, type TraceRow } from "@nanobpm/bojtos-kit";
2
+ /**
3
+ * The shared activity log — the run told as a story rather than a flat stack of
4
+ * lines. It is the single component that retired the two drifted, forked
5
+ * `TraceTimeline` copies (nanobpm/bojtos#9): the web-demo framework's agent/tool/
6
+ * turn view and the console test-view's engine-event fold.
7
+ *
8
+ * It renders the framework-agnostic {@link TraceRow} model from
9
+ * `@nanobpm/bojtos-kit` — feed it whichever adapter matches your source:
10
+ *
11
+ * - `foldEngineEvents(useBojtos().events)` for the non-agentic / test-view case, or
12
+ * - `traceEntriesToRows(entries)` for handler-emitted agent/tool/turn entries.
13
+ *
14
+ * Consecutive rows sharing a `turn` fold into one card (the model's raw LLM reply,
15
+ * each tool it activated with its arguments, and — once it lands — what that tool
16
+ * returned); rows with no `turn` render as plain lines in order, so a non-agentic
17
+ * run looks exactly like the flat log it replaces.
18
+ *
19
+ * This module deliberately imports **only** the kit and React — never
20
+ * `./BpmnRuntimeView` or `bpmn-js` — so a trace-only import tree-shakes the
21
+ * diagram renderer out (the package is `sideEffects: false`). See
22
+ * `test/trace.timeline.test.ts`, which walks the built module graph to pin that.
23
+ *
24
+ * The markup keeps the class names the forked copies' CSS already targets
25
+ * (`timeline`, `timeline-turn`, `timeline-tool`, `log-line log-<kind>`, …) so a
26
+ * consumer's existing stylesheet applies unchanged; no design-system dependency
27
+ * is pulled in.
28
+ */
29
+ export interface TraceTimelineProps {
30
+ /**
31
+ * The normalized rows to render. Produce them with a kit adapter
32
+ * (`foldEngineEvents` / `traceEntriesToRows`) or your own {@link TraceRow[]}.
33
+ */
34
+ rows: TraceRow[];
35
+ /** `snapshot.elementStats` — per-element completion/active counts, engine-side. */
36
+ elementStats?: ElementStatDto[];
37
+ /** Incidents on the current snapshot, with their reason. */
38
+ incidents?: IncidentDto[];
39
+ /** BPMN element id → human label, for both the timeline and the panels below. */
40
+ labelFor?: (elementId: string) => string;
41
+ /** Card heading. Defaults to "Activity". */
42
+ title?: string;
43
+ /** Sub-heading under the title. */
44
+ description?: string;
45
+ /** Shown when there are no rows yet. Defaults to "Press Run to start.". */
46
+ emptyText?: string;
47
+ /** Optional class for the outer container. */
48
+ className?: string;
49
+ }
50
+ export declare function TraceTimeline({ rows, elementStats, incidents, labelFor, title, description, emptyText, className, }: TraceTimelineProps): import("react").JSX.Element;
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { buildTraceItems, isTraceTurnGroup, } from "@nanobpm/bojtos-kit";
4
+ function safeStringify(value, space) {
5
+ // `JSON.stringify(undefined)` returns the JS value `undefined` (not a string),
6
+ // which would render as nothing. Emit the literal "undefined" so a handler/tool
7
+ // that actually returned `undefined` is shown explicitly rather than vanishing.
8
+ if (value === undefined)
9
+ return "undefined";
10
+ try {
11
+ // `JSON.stringify` throws on BigInt and circular structures; a replacer
12
+ // renders BigInt losslessly as its decimal string so trace payloads that
13
+ // carry engine-native BigInts don't crash serialization.
14
+ return JSON.stringify(value, (_key, val) => (typeof val === "bigint" ? val.toString() : val), space);
15
+ }
16
+ catch {
17
+ return "[unserializable value]";
18
+ }
19
+ }
20
+ function ToolStep({ activation, result, labelFor, }) {
21
+ const elementId = activation.elementId ?? "";
22
+ return (_jsxs("div", { className: "timeline-tool", children: [_jsxs("div", { className: "timeline-tool-head", children: [_jsx("span", { className: "timeline-badge timeline-badge-info", children: "tool" }), _jsx("strong", { children: labelFor(elementId) || elementId }), _jsx("code", { children: elementId })] }), activation.args !== undefined &&
23
+ Object.keys(activation.args).length > 0 && (_jsxs("div", { className: "timeline-kv", children: [_jsx("span", { className: "timeline-kv-label", children: "arguments" }), _jsx("code", { children: safeStringify(activation.args) })] })), _jsxs("div", { className: "timeline-kv", children: [_jsx("span", { className: "timeline-kv-label", children: "returned" }), _jsx("code", { children: result
24
+ ? safeStringify(result.result)
25
+ : "— waiting for the job to complete —" })] })] }));
26
+ }
27
+ function TurnCard({ group, labelFor, }) {
28
+ const reply = group.rows.find((e) => e.kind === "llm");
29
+ const activations = group.rows.filter((e) => e.kind === "agent" && e.elementId);
30
+ const results = group.rows.filter((e) => e.kind === "vars" && e.elementId);
31
+ const decisions = group.rows.filter((e) => e.kind === "agent" && !e.elementId);
32
+ const errors = group.rows.filter((e) => e.kind === "error");
33
+ // Entries a handler's own trace call emits (kind "tool") and any "vars" result
34
+ // that never paired with an activation above would otherwise vanish once
35
+ // stamped with a turn — render them as plain lines within the card, in order.
36
+ const activatedElementIds = new Set(activations.map((a) => a.elementId));
37
+ const loose = group.rows
38
+ .filter((e) => e.kind === "tool" ||
39
+ (e.kind === "vars" &&
40
+ e.elementId &&
41
+ !activatedElementIds.has(e.elementId)))
42
+ .sort((a, b) => a.id - b.id);
43
+ return (_jsxs("div", { className: "timeline-turn", children: [_jsxs("div", { className: "timeline-turn-head", children: [_jsxs("span", { className: `timeline-badge ${reply?.pending ? "timeline-badge-warning" : "timeline-badge-neutral"}`, children: ["Turn ", group.turn] }), reply?.pending && _jsx("span", { className: "timeline-pending", children: "thinking\u2026" })] }), reply && _jsx("blockquote", { className: "timeline-reply", children: reply.text }), decisions.map((d) => (_jsx("div", { className: "timeline-note", children: d.text }, d.key ?? d.id))), activations.map((a) => (_jsx(ToolStep, { activation: a, result: results.find((r) => r.elementId === a.elementId), labelFor: labelFor }, a.key ?? a.id))), loose.map((e) => (_jsxs("div", { className: `log-line log-${e.kind}`, children: [e.pending ? "⏳ " : "", e.text] }, e.key ?? e.id))), errors.map((e) => (_jsxs("div", { className: "timeline-error", children: ["\u26A0 ", e.text] }, e.id)))] }));
44
+ }
45
+ export function TraceTimeline({ rows, elementStats = [], incidents = [], labelFor = (id) => id, title = "Activity", description = "Agent turns, model replies, and tool calls — read top to bottom as a story.", emptyText = "Press Run to start.", className, }) {
46
+ const items = useMemo(() => buildTraceItems(rows), [rows]);
47
+ const [copied, setCopied] = useState(false);
48
+ const scrollRef = useRef(null);
49
+ // Keep the newest step in view as the run grows, same as the flat logs this
50
+ // replaces.
51
+ useEffect(() => {
52
+ const el = scrollRef.current;
53
+ if (el)
54
+ el.scrollTop = el.scrollHeight;
55
+ }, [items]);
56
+ const copyJson = () => {
57
+ const payload = {
58
+ log: rows.map(({ id: _id, ...rest }) => rest),
59
+ elementStats,
60
+ incidents,
61
+ };
62
+ const text = safeStringify(payload, 2);
63
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
64
+ navigator.clipboard
65
+ .writeText(text)
66
+ .then(() => {
67
+ setCopied(true);
68
+ setTimeout(() => setCopied(false), 1500);
69
+ })
70
+ .catch(() => {
71
+ // Clipboard access can be denied (permissions policy, insecure
72
+ // context, an embed iframe without a clipboard-write allowance) —
73
+ // fail quietly rather than surfacing an error for a convenience
74
+ // action, not a run-blocking one.
75
+ });
76
+ }
77
+ };
78
+ return (_jsxs("div", { className: className ? `timeline-panel ${className}` : "timeline-panel", children: [_jsxs("div", { className: "timeline-header", children: [_jsx("div", { className: "timeline-title", children: title }), description && (_jsx("div", { className: "timeline-description", children: description }))] }), _jsx("div", { className: "timeline-toolbar", children: _jsx("button", { type: "button", onClick: copyJson, children: copied ? "Copied!" : "Copy run as JSON" }) }), _jsx("div", { className: "timeline", ref: scrollRef, children: items.length === 0 ? (_jsx("div", { className: "log-empty", children: emptyText })) : (items.map((item) => isTraceTurnGroup(item) ? (_jsx(TurnCard, { group: item, labelFor: labelFor }, `turn-${item.turn}-${item.rows[0].key ?? item.rows[0].id}`)) : (_jsxs("div", { className: `log-line log-${item.kind}`, children: [item.pending ? "⏳ " : "", item.text] }, item.key ?? item.id)))) }), (elementStats.length > 0 || incidents.length > 0) && (_jsxs("div", { className: "timeline-engine-view", children: [elementStats.length > 0 && (_jsxs("div", { className: "timeline-stats", children: [_jsx("span", { className: "timeline-kv-label", children: "Element completion" }), _jsx("ul", { children: elementStats
79
+ .filter((s) => s.completed > 0 || (s.active ?? 0) > 0)
80
+ .map((s) => (_jsxs("li", { children: [_jsx("code", { children: labelFor(s.elementId) || s.elementId }), " ", "completed ", s.completed, s.active ? `, ${s.active} active` : ""] }, s.elementId))) })] })), incidents.length > 0 && (_jsxs("div", { className: "timeline-incidents", children: [_jsx("span", { className: "timeline-kv-label", children: "Incidents" }), _jsx("ul", { children: incidents.map((inc, i) => (_jsxs("li", { children: [_jsx("code", { children: labelFor(inc.elementId) || inc.elementId }), " \u2014", " ", inc.reason] }, `${inc.elementId}-${i}`))) })] }))] }))] }));
81
+ }
package/dist/index.d.ts CHANGED
@@ -2,5 +2,7 @@ export { useBojtos, type UseBojtosOptions, type BojtosControls, type BojtosPhase
2
2
  export { Bojtos, type BojtosProps, type TraceEvent } from "./Bojtos.js";
3
3
  export { OrderFulfillmentDemo, ORDER_FULFILLMENT_BPMN, orderFulfillmentWorkers, } from "./examples/orderFulfillment.js";
4
4
  export { BpmnRuntimeView, type BpmnRuntimeViewProps, } from "./BpmnRuntimeView.js";
5
- export { JobFailure, type JobHandler, type JobResult, type AgentHandler, type DispatchOptions, type DispatchResult, type RoundResult, } from "@nanobpm/bojtos-kit";
6
- export type { BojtosSession, Snapshot, InstanceDto, JobDto, ActivatedJob, IncidentDto, TimerDto, ActiveEl, AgentActivation, AgentResult, WasmEvent, } from "@nanobpm/bojtos-kit";
5
+ export { TraceTimeline, type TraceTimelineProps } from "./TraceTimeline.js";
6
+ export { describeRunState, markerKey, bpmnKey, resourceList, capEvents, } from "./runState.js";
7
+ export { JobFailure, settleReason, unhandledJobTypes, buildTraceItems, isTraceTurnGroup, foldEngineEvents, traceEntriesToRows, type JobHandler, type JobResult, type AgentHandler, type DispatchOptions, type DispatchResult, type RoundResult, type SettleReason, } from "@nanobpm/bojtos-kit";
8
+ export type { BojtosSession, Snapshot, InstanceDto, JobDto, ActivatedJob, IncidentDto, TimerDto, UserTaskDto, MessageSubscriptionDto, SignalSubscriptionDto, ElementStatDto, SequenceFlowDto, DecisionInstanceDto, ActiveEl, ActivateInstruction, AgentActivation, AgentResult, WasmEvent, TraceRowKind, TraceEntry, TraceRow, TraceTurnGroup, TraceItem, TraceAdapter, } from "@nanobpm/bojtos-kit";
package/dist/index.js CHANGED
@@ -7,4 +7,8 @@ export { useBojtos, } from "./useBojtos.js";
7
7
  export { Bojtos } from "./Bojtos.js";
8
8
  export { OrderFulfillmentDemo, ORDER_FULFILLMENT_BPMN, orderFulfillmentWorkers, } from "./examples/orderFulfillment.js";
9
9
  export { BpmnRuntimeView, } from "./BpmnRuntimeView.js";
10
- export { JobFailure, } from "@nanobpm/bojtos-kit";
10
+ // The shared activity log (#9). Trace-only imports tree-shake bpmn-js out —
11
+ // TraceTimeline imports only the kit + React, never BpmnRuntimeView.
12
+ export { TraceTimeline } from "./TraceTimeline.js";
13
+ export { describeRunState, markerKey, bpmnKey, resourceList, capEvents, } from "./runState.js";
14
+ export { JobFailure, settleReason, unhandledJobTypes, buildTraceItems, isTraceTurnGroup, foldEngineEvents, traceEntriesToRows, } from "@nanobpm/bojtos-kit";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Pure helpers behind the React binding's decisions.
3
+ *
4
+ * Kept out of the component and the hook — and out of any module that imports
5
+ * bpmn-js — so they can be tested without a DOM or the peer dependency. The
6
+ * component and hook hold rendering and lifecycle; what counts as a change, how
7
+ * a run reads aloud, which resources to deploy and how far to trim the log are
8
+ * decisions, and decisions are worth testing.
9
+ */
10
+ /**
11
+ * Stable key for a marker set, so unchanged ids don't re-paint the diagram.
12
+ *
13
+ * Relies on BPMN element ids being XML NCNames (so they can't contain `,` or
14
+ * `|`) — that invariant is what keeps the two-field join collision-free. If ids
15
+ * could contain the delimiters, `["a,b"],[]` and `["a"],["b"]`-style pairs
16
+ * would key alike.
17
+ */
18
+ export declare function markerKey(activeIds: string[], incidentIds: string[]): string;
19
+ /**
20
+ * Content key for the `bpmn` prop, so a fresh array identity each render doesn't
21
+ * re-create the engine but a real content change still does. Only the array
22
+ * case is serialized (with a boundary-preserving `JSON.stringify`, not a
23
+ * `join` — a delimiter join lets two different resource arrays collapse to one
24
+ * key when a resource borders/contains the delimiter, silently missing a real
25
+ * change). A lone string can't have array-boundary collisions and React deps
26
+ * already compare strings by value, so it passes through untouched — no needless
27
+ * re-walk of potentially large BPMN XML each render.
28
+ */
29
+ export declare function bpmnKey(bpmn: string | string[]): string;
30
+ /**
31
+ * Normalize the `bpmn` prop to an ordered resource list for deployment. Deploy
32
+ * order is significant: a later resource can reference an earlier one (a call
33
+ * activity's child), so the array order is preserved verbatim.
34
+ */
35
+ export declare function resourceList(bpmn: string | string[]): string[];
36
+ /**
37
+ * Trim an event log to the consumer's cap, keeping the most recent events.
38
+ * `undefined` or a negative cap means "no cap"; `0` means "keep nothing". The
39
+ * input array is never mutated.
40
+ */
41
+ export declare function capEvents<T>(all: T[], cap: number | undefined): T[];
42
+ /**
43
+ * A one-line description of run state, for the diagram's live region. The token
44
+ * and incident highlights are purely visual; this is the same information as
45
+ * text.
46
+ */
47
+ export declare function describeRunState(activeIds: string[], incidentIds: string[], name?: (id: string) => string): string;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pure helpers behind the React binding's decisions.
3
+ *
4
+ * Kept out of the component and the hook — and out of any module that imports
5
+ * bpmn-js — so they can be tested without a DOM or the peer dependency. The
6
+ * component and hook hold rendering and lifecycle; what counts as a change, how
7
+ * a run reads aloud, which resources to deploy and how far to trim the log are
8
+ * decisions, and decisions are worth testing.
9
+ */
10
+ /**
11
+ * Stable key for a marker set, so unchanged ids don't re-paint the diagram.
12
+ *
13
+ * Relies on BPMN element ids being XML NCNames (so they can't contain `,` or
14
+ * `|`) — that invariant is what keeps the two-field join collision-free. If ids
15
+ * could contain the delimiters, `["a,b"],[]` and `["a"],["b"]`-style pairs
16
+ * would key alike.
17
+ */
18
+ export function markerKey(activeIds, incidentIds) {
19
+ return `${activeIds.join(",")}|${incidentIds.join(",")}`;
20
+ }
21
+ /**
22
+ * Content key for the `bpmn` prop, so a fresh array identity each render doesn't
23
+ * re-create the engine but a real content change still does. Only the array
24
+ * case is serialized (with a boundary-preserving `JSON.stringify`, not a
25
+ * `join` — a delimiter join lets two different resource arrays collapse to one
26
+ * key when a resource borders/contains the delimiter, silently missing a real
27
+ * change). A lone string can't have array-boundary collisions and React deps
28
+ * already compare strings by value, so it passes through untouched — no needless
29
+ * re-walk of potentially large BPMN XML each render.
30
+ */
31
+ export function bpmnKey(bpmn) {
32
+ return Array.isArray(bpmn) ? JSON.stringify(bpmn) : bpmn;
33
+ }
34
+ /**
35
+ * Normalize the `bpmn` prop to an ordered resource list for deployment. Deploy
36
+ * order is significant: a later resource can reference an earlier one (a call
37
+ * activity's child), so the array order is preserved verbatim.
38
+ */
39
+ export function resourceList(bpmn) {
40
+ return Array.isArray(bpmn) ? bpmn : [bpmn];
41
+ }
42
+ /**
43
+ * Trim an event log to the consumer's cap, keeping the most recent events.
44
+ * `undefined` or a negative cap means "no cap"; `0` means "keep nothing". The
45
+ * input array is never mutated.
46
+ */
47
+ export function capEvents(all, cap) {
48
+ return cap !== undefined && cap >= 0 && all.length > cap
49
+ ? all.slice(all.length - cap)
50
+ : all;
51
+ }
52
+ /**
53
+ * A one-line description of run state, for the diagram's live region. The token
54
+ * and incident highlights are purely visual; this is the same information as
55
+ * text.
56
+ */
57
+ export function describeRunState(activeIds, incidentIds, name = (id) => id) {
58
+ const parts = [];
59
+ if (activeIds.length)
60
+ parts.push(`Running: ${activeIds.map(name).join(", ")}`);
61
+ if (incidentIds.length)
62
+ parts.push(`Incident: ${incidentIds.map(name).join(", ")}`);
63
+ return parts.length ? parts.join(". ") : "Nothing running";
64
+ }
@@ -1,9 +1,15 @@
1
- import { type AgentResult, type DispatchOptions, type JobHandler, type RoundResult, type Snapshot, type WasmEvent, type WasmSource } from "@nanobpm/bojtos-kit";
1
+ import { type ActivateInstruction, type AgentResult, type DispatchOptions, type JobHandler, type RoundResult, type Snapshot, type WasmEvent, type WasmSource } from "@nanobpm/bojtos-kit";
2
2
  /** Lifecycle of the in-browser engine load. */
3
3
  export type BojtosPhase = "loading" | "ready" | "error";
4
4
  export interface UseBojtosOptions {
5
- /** The BPMN diagram XML to deploy. Re-deploys on a fresh engine when it changes. */
6
- bpmn: string;
5
+ /**
6
+ * The BPMN to deploy. Re-deploys on a fresh engine when it changes.
7
+ *
8
+ * Pass an array to deploy several resources into one engine — a called
9
+ * process alongside its parent, say. `processIds` then lists every deployable
10
+ * process across all of them, in deployment order.
11
+ */
12
+ bpmn: string | string[];
7
13
  /**
8
14
  * Optional engine wasm source. Pass a `URL` / bytes / `WebAssembly.Module`
9
15
  * when the default `import.meta.url` loader can't resolve the binary (the
@@ -14,6 +20,15 @@ export interface UseBojtosOptions {
14
20
  * reload the module.
15
21
  */
16
22
  wasm?: WasmSource;
23
+ /**
24
+ * Cap the reactive `events` log at the most recent N entries.
25
+ *
26
+ * Every command re-reads the engine's full event log into React state, so a
27
+ * long-running demo copies an ever-growing array on each step. Set this when
28
+ * a page runs for a while and only shows a tail; leave it unset to keep the
29
+ * whole log, which stays the default so existing consumers are unaffected.
30
+ */
31
+ maxEvents?: number;
17
32
  }
18
33
  export interface BojtosControls {
19
34
  phase: BojtosPhase;
@@ -46,6 +61,58 @@ export interface BojtosControls {
46
61
  correlateMessage(messageName: string, correlationKey: string, variablesJson: string): Snapshot | null;
47
62
  /** Advance the virtual clock. */
48
63
  advanceTime(byMs: number): Snapshot | null;
64
+ /**
65
+ * Throw a BPMN business error from a waiting job: interrupts the activity via
66
+ * a matching error boundary/event-subprocess catch, or raises an incident if
67
+ * uncaught. The job is consumed either way.
68
+ */
69
+ throwError(jobKey: string, errorCode: string, errorMessage: string): Snapshot | null;
70
+ /**
71
+ * Set a job's remaining retries. Used to recover a job parked on a no-retries
72
+ * incident before resolving that incident; does not itself unblock the job.
73
+ */
74
+ updateRetries(jobKey: string, retries: number): Snapshot | null;
75
+ /**
76
+ * Resolve an open incident by key, retrying the work that failed. Pair with
77
+ * {@link updateRetries} to make a failed job activatable again — the
78
+ * incident/retry loop a demo needs to show recovery.
79
+ */
80
+ resolveIncident(incidentKey: string): Snapshot | null;
81
+ /**
82
+ * Merge variables into a scope (a process-instance or element-instance key).
83
+ * With `local`, they are written strictly into that scope; otherwise they
84
+ * propagate up to the nearest ancestor defining each name.
85
+ */
86
+ setVariables(scopeKey: string, variablesJson: string, local: boolean): Snapshot | null;
87
+ /** Broadcast a signal by name to every matching open subscription. */
88
+ broadcastSignal(signalName: string, variablesJson: string): Snapshot | null;
89
+ /** Cancel (terminate) a running process instance. */
90
+ cancelInstance(instanceKey: string): Snapshot | null;
91
+ /**
92
+ * Modify a running instance: terminate element instances and/or activate new
93
+ * ones (Zeebe "modify process instance").
94
+ */
95
+ modify(instanceKey: string, activateInstructions: ActivateInstruction[], terminateElementInstanceKeys: string[]): Snapshot | null;
96
+ /**
97
+ * Complete a waiting user task, merging output variables.
98
+ *
99
+ * A `userTask` produces no job, so the dispatch loop cannot advance one: this
100
+ * is the only way a model with a human step reaches its end event. Drive it
101
+ * from `snapshot.userTasks`.
102
+ */
103
+ completeUserTask(userTaskKey: string, variablesJson: string): Snapshot | null;
104
+ /**
105
+ * Assign a user task. With `allowOverride` false the command is rejected if
106
+ * the task already has an assignee.
107
+ */
108
+ assignUserTask(userTaskKey: string, assignee: string, allowOverride: boolean): Snapshot | null;
109
+ /** Clear a user task's assignee. */
110
+ unassignUserTask(userTaskKey: string): Snapshot | null;
111
+ /**
112
+ * Update a user task's attributes from a JSON changeset (`candidateGroups`,
113
+ * `candidateUsers`, `dueDate`, `followUpDate`, `priority`).
114
+ */
115
+ updateUserTask(userTaskKey: string, changesetJson: string): Snapshot | null;
49
116
  /**
50
117
  * Run the registered worker handlers until the process settles (activate →
51
118
  * handler → complete/fail), then reflect the resulting snapshot/events.
@@ -76,4 +143,4 @@ export interface BojtosControls {
76
143
  * test-run panel is its first consumer (§8 step 2 — dogfooding is the acceptance
77
144
  * test).
78
145
  */
79
- export declare function useBojtos({ bpmn, wasm }: UseBojtosOptions): BojtosControls;
146
+ export declare function useBojtos({ bpmn, wasm, maxEvents, }: UseBojtosOptions): BojtosControls;
package/dist/useBojtos.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { createBojtosSession, dispatchRound, dispatchWorkers, } from "@nanobpm/bojtos-kit";
3
+ import { bpmnKey, capEvents, resourceList } from "./runState.js";
3
4
  /**
4
5
  * React binding over a headless {@link BojtosSession}: owns the engine's
5
6
  * lifecycle and the reactive `snapshot` / `events` / `processIds` state, and
@@ -11,7 +12,7 @@ import { createBojtosSession, dispatchRound, dispatchWorkers, } from "@nanobpm/b
11
12
  * test-run panel is its first consumer (§8 step 2 — dogfooding is the acceptance
12
13
  * test).
13
14
  */
14
- export function useBojtos({ bpmn, wasm }) {
15
+ export function useBojtos({ bpmn, wasm, maxEvents, }) {
15
16
  const sessionRef = useRef(null);
16
17
  const [phase, setPhase] = useState("loading");
17
18
  const [error, setError] = useState(null);
@@ -23,13 +24,32 @@ export function useBojtos({ bpmn, wasm }) {
23
24
  // identity each render must not re-create the session.
24
25
  const wasmRef = useRef(wasm);
25
26
  wasmRef.current = wasm;
27
+ // An array prop has a fresh identity every render, which would re-create the
28
+ // engine on each one. Key the deploy effect on the content instead — see
29
+ // `bpmnKey` for why this is a boundary-preserving serialization, not a join.
30
+ const deployKey = bpmnKey(bpmn);
31
+ const bpmnRef = useRef(bpmn);
32
+ bpmnRef.current = bpmn;
33
+ // Trim the reactive event log when the consumer asked for a cap.
34
+ const maxEventsRef = useRef(maxEvents);
35
+ maxEventsRef.current = maxEvents;
36
+ const readEvents = useCallback((session) => {
37
+ return capEvents(session.events(), maxEventsRef.current);
38
+ }, []);
26
39
  const deployInto = useCallback((session) => {
27
- const res = session.deploy(bpmn);
28
- setProcessIds(res.processIds);
40
+ const resources = resourceList(bpmnRef.current);
41
+ // Deploy in order, collecting every deployable process id. A later
42
+ // resource can reference an earlier one (a call activity's child).
43
+ const ids = [];
44
+ for (const xml of resources)
45
+ ids.push(...session.deploy(xml).processIds);
46
+ setProcessIds(ids);
29
47
  setSnapshot(null);
30
48
  setEvents([]);
31
49
  setError(null);
32
- }, [bpmn]);
50
+ },
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ [deployKey]);
33
53
  useEffect(() => {
34
54
  let cancelled = false;
35
55
  // A new diagram means a fresh engine: drop back to `loading` and clear the
@@ -81,7 +101,7 @@ export function useBojtos({ bpmn, wasm }) {
81
101
  try {
82
102
  const snap = fn(session);
83
103
  setSnapshot(snap);
84
- setEvents(session.events());
104
+ setEvents(readEvents(session));
85
105
  setError(null);
86
106
  return snap;
87
107
  }
@@ -96,6 +116,17 @@ export function useBojtos({ bpmn, wasm }) {
96
116
  const failJob = useCallback((jobKey, retries, message) => run((s) => s.failJob(jobKey, retries, message)), [run]);
97
117
  const advanceTime = useCallback((byMs) => run((s) => s.advanceTime(byMs)), [run]);
98
118
  const correlateMessage = useCallback((messageName, correlationKey, variablesJson) => run((s) => s.correlateMessage(messageName, correlationKey, variablesJson)), [run]);
119
+ const throwError = useCallback((jobKey, errorCode, errorMessage) => run((s) => s.throwError(jobKey, errorCode, errorMessage)), [run]);
120
+ const updateRetries = useCallback((jobKey, retries) => run((s) => s.updateRetries(jobKey, retries)), [run]);
121
+ const resolveIncident = useCallback((incidentKey) => run((s) => s.resolveIncident(incidentKey)), [run]);
122
+ const setVariables = useCallback((scopeKey, variablesJson, local) => run((s) => s.setVariables(scopeKey, variablesJson, local)), [run]);
123
+ const broadcastSignal = useCallback((signalName, variablesJson) => run((s) => s.broadcastSignal(signalName, variablesJson)), [run]);
124
+ const cancelInstance = useCallback((instanceKey) => run((s) => s.cancelInstance(instanceKey)), [run]);
125
+ const modify = useCallback((instanceKey, activateInstructions, terminateElementInstanceKeys) => run((s) => s.modify(instanceKey, activateInstructions, terminateElementInstanceKeys)), [run]);
126
+ const completeUserTask = useCallback((userTaskKey, variablesJson) => run((s) => s.completeUserTask(userTaskKey, variablesJson)), [run]);
127
+ const assignUserTask = useCallback((userTaskKey, assignee, allowOverride) => run((s) => s.assignUserTask(userTaskKey, assignee, allowOverride)), [run]);
128
+ const unassignUserTask = useCallback((userTaskKey) => run((s) => s.unassignUserTask(userTaskKey)), [run]);
129
+ const updateUserTask = useCallback((userTaskKey, changesetJson) => run((s) => s.updateUserTask(userTaskKey, changesetJson)), [run]);
99
130
  const runWorkers = useCallback(async (workers, opts) => {
100
131
  const session = sessionRef.current;
101
132
  if (!session)
@@ -107,7 +138,7 @@ export function useBojtos({ bpmn, wasm }) {
107
138
  if (sessionRef.current !== session)
108
139
  return null;
109
140
  setSnapshot(settled);
110
- setEvents(session.events());
141
+ setEvents(readEvents(session));
111
142
  setError(null);
112
143
  return settled;
113
144
  }
@@ -117,7 +148,7 @@ export function useBojtos({ bpmn, wasm }) {
117
148
  // Reflect whatever state the engine reached before the drain aborted
118
149
  // (e.g. the maxRounds guard) so the view isn't left stale.
119
150
  setSnapshot(session.snapshot());
120
- setEvents(session.events());
151
+ setEvents(readEvents(session));
121
152
  setError(String(e));
122
153
  return null;
123
154
  }
@@ -132,7 +163,7 @@ export function useBojtos({ bpmn, wasm }) {
132
163
  if (sessionRef.current !== session)
133
164
  return null;
134
165
  setSnapshot(round.snapshot);
135
- setEvents(session.events());
166
+ setEvents(readEvents(session));
136
167
  setError(null);
137
168
  return round;
138
169
  }
@@ -140,7 +171,7 @@ export function useBojtos({ bpmn, wasm }) {
140
171
  if (sessionRef.current !== session)
141
172
  return null;
142
173
  setSnapshot(session.snapshot());
143
- setEvents(session.events());
174
+ setEvents(readEvents(session));
144
175
  setError(String(e));
145
176
  return null;
146
177
  }
@@ -172,6 +203,17 @@ export function useBojtos({ bpmn, wasm }) {
172
203
  failJob,
173
204
  advanceTime,
174
205
  correlateMessage,
206
+ throwError,
207
+ updateRetries,
208
+ resolveIncident,
209
+ setVariables,
210
+ broadcastSignal,
211
+ cancelInstance,
212
+ modify,
213
+ completeUserTask,
214
+ assignUserTask,
215
+ unassignUserTask,
216
+ updateUserTask,
175
217
  runWorkers,
176
218
  stepWorkers,
177
219
  reset,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/bojtos-react",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "React binding for the Bojtos in-browser BPMN demo framework (ADR 0043): the useBojtos hook (owns the engine session + reactive snapshot/event state) and the <BpmnRuntimeView> live token/incident diagram. Built on @nanobpm/bojtos-kit; the console test-run panel is its first consumer.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -27,10 +27,12 @@
27
27
  "scripts": {
28
28
  "build": "tsc -p tsconfig.json",
29
29
  "typecheck": "tsc -p tsconfig.json --noEmit",
30
- "prepack": "npm run build"
30
+ "prepack": "npm run build",
31
+ "test": "npm run build && npm run test:ci",
32
+ "test:ci": "node --experimental-strip-types --test test/*.test.ts"
31
33
  },
32
34
  "dependencies": {
33
- "@nanobpm/bojtos-kit": "^0.4.0"
35
+ "@nanobpm/bojtos-kit": "^0.6.0"
34
36
  },
35
37
  "peerDependencies": {
36
38
  "bpmn-js": ">=17",