@nanobpm/nano-workforce 0.193.1 → 0.194.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/CHANGELOG.md +12 -0
- package/app/agentic/cockpit/agent-history-render.test.ts +24 -0
- package/app/agentic/cockpit/agent-history-render.ts +18 -4
- package/app/agentic/cockpit/agent-history-view.test.ts +16 -0
- package/app/agentic/cockpit/agent-history-view.ts +17 -0
- package/app/agentic/cockpit/mount.test.ts +12 -0
- package/app/persist-escalation.test.ts +56 -15
- package/app/persistEscalationElements.test.ts +40 -0
- package/app/pollUserTasks.test.ts +572 -3
- package/app/service.ts +158 -0
- package/app/userTasks.ts +34 -0
- package/package.json +2 -2
- package/pages/cockpit/cockpit.css +17 -1
- package/pages/cockpit/mount.js +27 -4
- package/workers/persist-escalation/worker.ts +29 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.194.0](https://github.com/nanobpm/nano-workforce/compare/v0.193.2...v0.194.0) (2026-09-30)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **cockpit:** collapse agent-history turns behind a timestamped heading ([#832](https://github.com/nanobpm/nano-workforce/issues/832)) ([812907d](https://github.com/nanobpm/nano-workforce/commit/812907d13c49dc8a008bb8682d7d58f04c8ba323)), closes [#831](https://github.com/nanobpm/nano-workforce/issues/831)
|
|
6
|
+
|
|
7
|
+
## [0.193.2](https://github.com/nanobpm/nano-workforce/compare/v0.193.1...v0.193.2) (2026-09-29)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **convergence:** heal PR rows stuck at status=escalated after escalation answered ([#829](https://github.com/nanobpm/nano-workforce/issues/829)) ([235a004](https://github.com/nanobpm/nano-workforce/commit/235a004f73ec1b27f52b5dfe88e2f2a28c1d99e8)), closes [#642](https://github.com/nanobpm/nano-workforce/issues/642) [#828](https://github.com/nanobpm/nano-workforce/issues/828) [#828](https://github.com/nanobpm/nano-workforce/issues/828) [#828](https://github.com/nanobpm/nano-workforce/issues/828) [#828](https://github.com/nanobpm/nano-workforce/issues/828)
|
|
12
|
+
|
|
1
13
|
## [0.193.1](https://github.com/nanobpm/nano-workforce/compare/v0.193.0...v0.193.1) (2026-09-28)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -63,6 +63,8 @@ test("renderAgentHistory renders turns with role, text, tool calls and per-turn
|
|
|
63
63
|
text: "on it",
|
|
64
64
|
toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool" }],
|
|
65
65
|
metrics: "12 in \u00b7 4 out \u00b7 900ms",
|
|
66
|
+
producedAt: "2024-05-01T12:34:56.789Z",
|
|
67
|
+
time: "12:34:56",
|
|
66
68
|
},
|
|
67
69
|
],
|
|
68
70
|
};
|
|
@@ -78,6 +80,28 @@ test("renderAgentHistory renders turns with role, text, tool calls and per-turn
|
|
|
78
80
|
assert.equal(host.byClass("cockpit-agent-turn-metrics")[0]?.text(), "12 in \u00b7 4 out \u00b7 900ms");
|
|
79
81
|
});
|
|
80
82
|
|
|
83
|
+
test("renderAgentHistory renders each turn collapsed: a <details> whose <summary> heading carries the timestamp (#831)", () => {
|
|
84
|
+
const host = new FakeElement("div");
|
|
85
|
+
renderAgentHistory(host, doc, {
|
|
86
|
+
agentInstanceKey: "ai-1",
|
|
87
|
+
count: 1,
|
|
88
|
+
turns: [{ historyItemKey: "h-1", loopIteration: 1, role: "ASSISTANT", text: "on it", toolCalls: [], producedAt: "2024-05-01T12:34:56.789Z", time: "12:34:56" }],
|
|
89
|
+
});
|
|
90
|
+
const turn = host.byClass("cockpit-agent-turn")[0];
|
|
91
|
+
assert.equal(turn?.tagName, "details");
|
|
92
|
+
// Collapsed by default: no `open` attribute — the native <summary> toggles it on click.
|
|
93
|
+
assert.equal(turn?.getAttribute("open"), undefined);
|
|
94
|
+
const heading = host.byClass("cockpit-agent-turn-meta")[0];
|
|
95
|
+
assert.equal(heading?.tagName, "summary");
|
|
96
|
+
const time = host.byClass("cockpit-agent-turn-time")[0];
|
|
97
|
+
assert.equal(time?.tagName, "time");
|
|
98
|
+
assert.equal(time?.text(), "12:34:56");
|
|
99
|
+
assert.equal(time?.getAttribute("datetime"), "2024-05-01T12:34:56.789Z");
|
|
100
|
+
// The details body sits inside the collapsible turn, not in the heading.
|
|
101
|
+
const body = host.byClass("cockpit-agent-turn-body")[0];
|
|
102
|
+
assert.equal(body?.byClass("cockpit-agent-turn-text")[0]?.text(), "on it");
|
|
103
|
+
});
|
|
104
|
+
|
|
81
105
|
test("renderAgentHistory renders an empty history state", () => {
|
|
82
106
|
const host = new FakeElement("div");
|
|
83
107
|
renderAgentHistory(host, doc, { agentInstanceKey: "ai-9", count: 0, turns: [] });
|
|
@@ -106,19 +106,32 @@ export function renderAgentSessions(
|
|
|
106
106
|
return { root };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* One turn as a native collapsible: a `<details>` (collapsed by default — no `open`) whose `<summary>`
|
|
111
|
+
* heading (role, iteration, timestamp, metrics) toggles the turn's details body on click (#831).
|
|
112
|
+
*/
|
|
109
113
|
function turnBlock(doc: DocumentLike, t: AgentTurnView): ElementLike {
|
|
110
|
-
const block = el(doc, "
|
|
114
|
+
const block = el(doc, "details", "cockpit-agent-turn");
|
|
111
115
|
block.setAttribute("data-history-item-key", t.historyItemKey);
|
|
112
116
|
block.setAttribute("data-role", t.role);
|
|
113
117
|
block.setAttribute("data-loop-iteration", String(t.loopIteration));
|
|
114
118
|
|
|
115
|
-
const meta = el(doc, "
|
|
119
|
+
const meta = el(doc, "summary", "cockpit-agent-turn-meta");
|
|
116
120
|
meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
|
|
117
121
|
meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
|
|
122
|
+
if (t.time !== undefined) {
|
|
123
|
+
const time = el(doc, "time", "cockpit-agent-turn-time", t.time);
|
|
124
|
+
if (t.producedAt !== undefined) {
|
|
125
|
+
time.setAttribute("datetime", t.producedAt);
|
|
126
|
+
time.setAttribute("title", t.producedAt);
|
|
127
|
+
}
|
|
128
|
+
meta.appendChild(time);
|
|
129
|
+
}
|
|
118
130
|
if (t.metrics !== undefined) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
|
|
119
131
|
block.appendChild(meta);
|
|
120
132
|
|
|
121
|
-
|
|
133
|
+
const body = el(doc, "div", "cockpit-agent-turn-body");
|
|
134
|
+
if (t.text !== "") body.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
|
|
122
135
|
|
|
123
136
|
if (t.toolCalls.length > 0) {
|
|
124
137
|
const tools = el(doc, "ul", "cockpit-agent-turn-tools");
|
|
@@ -127,8 +140,9 @@ function turnBlock(doc: DocumentLike, t: AgentTurnView): ElementLike {
|
|
|
127
140
|
li.setAttribute("data-tool-call-id", call.toolCallId);
|
|
128
141
|
tools.appendChild(li);
|
|
129
142
|
}
|
|
130
|
-
|
|
143
|
+
body.appendChild(tools);
|
|
131
144
|
}
|
|
145
|
+
block.appendChild(body);
|
|
132
146
|
return block;
|
|
133
147
|
}
|
|
134
148
|
|
|
@@ -96,6 +96,22 @@ test("agentHistoryView projects turns in transport order with text, tool calls a
|
|
|
96
96
|
assert.equal(view.turns[1]?.metrics, "12 in \u00b7 4 out \u00b7 900ms");
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
test("agentHistoryView carries each turn's producedAt timestamp (absent when unreported)", () => {
|
|
100
|
+
const view = agentHistoryView({
|
|
101
|
+
agentInstanceKey: "ai-1",
|
|
102
|
+
count: 2,
|
|
103
|
+
records: [
|
|
104
|
+
{ historyItemKey: "h-0", agentInstanceKey: "ai-1", loopIteration: 0, role: "USER", commitStatus: "COMMITTED", content: [], toolCalls: [], producedAt: "2024-05-01T12:34:56.789Z" },
|
|
105
|
+
{ historyItemKey: "h-1", agentInstanceKey: "ai-1", loopIteration: 1, role: "ASSISTANT", commitStatus: "COMMITTED", content: [], toolCalls: [] },
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
assert.equal(view.turns[0]?.producedAt, "2024-05-01T12:34:56.789Z");
|
|
109
|
+
// UTC: the heading is locale-free, so the expected value is fixed regardless of the runner's timezone.
|
|
110
|
+
assert.equal(view.turns[0]?.time, "12:34:56");
|
|
111
|
+
assert.equal(view.turns[1]?.producedAt, undefined);
|
|
112
|
+
assert.equal(view.turns[1]?.time, undefined);
|
|
113
|
+
});
|
|
114
|
+
|
|
99
115
|
test("agentHistoryView read-as-absence: an empty history yields zero turns", () => {
|
|
100
116
|
const view = agentHistoryView({ agentInstanceKey: "ai-9", count: 0, records: [] });
|
|
101
117
|
assert.equal(view.count, 0);
|
|
@@ -63,6 +63,10 @@ export interface AgentTurnView {
|
|
|
63
63
|
readonly toolCalls: readonly AgentTurnToolCallView[];
|
|
64
64
|
/** A compact per-turn token/duration rollup, when metrics exist. */
|
|
65
65
|
readonly metrics?: string;
|
|
66
|
+
/** When the turn was produced (ISO-8601), when the engine reports it. */
|
|
67
|
+
readonly producedAt?: string;
|
|
68
|
+
/** The turn's compact heading timestamp (`HH:MM:SS`, UTC), when `producedAt` parses. */
|
|
69
|
+
readonly time?: string;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
/** The full renderable history for one agent instance: its rolled-up header + ordered turns. */
|
|
@@ -113,6 +117,16 @@ function turnMetrics(r: AgentHistoryRecord): string | undefined {
|
|
|
113
117
|
return dur !== undefined ? `${base} \u00b7 ${dur}` : base;
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
/** A compact `HH:MM:SS` heading timestamp in UTC for an ISO instant; undefined when unparseable. */
|
|
121
|
+
export function turnTime(iso: string | undefined): string | undefined {
|
|
122
|
+
if (iso === undefined || iso === "") return undefined;
|
|
123
|
+
const ms = Date.parse(iso);
|
|
124
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
125
|
+
const d = new Date(ms);
|
|
126
|
+
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
127
|
+
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
116
130
|
/** The textual content of a turn: TEXT blocks joined in order (non-textual/empty blocks dropped). */
|
|
117
131
|
function turnText(r: AgentHistoryRecord): string {
|
|
118
132
|
const texts: string[] = [];
|
|
@@ -156,6 +170,7 @@ export function agentSessionsView(report: AgentInstanceListReport): AgentSession
|
|
|
156
170
|
/** Project one engine {@link AgentHistoryRecord} onto a renderable {@link AgentTurnView}. */
|
|
157
171
|
export function agentTurnView(r: AgentHistoryRecord): AgentTurnView {
|
|
158
172
|
const metrics = turnMetrics(r);
|
|
173
|
+
const time = turnTime(r.producedAt);
|
|
159
174
|
return {
|
|
160
175
|
historyItemKey: r.historyItemKey,
|
|
161
176
|
loopIteration: r.loopIteration,
|
|
@@ -167,6 +182,8 @@ export function agentTurnView(r: AgentHistoryRecord): AgentTurnView {
|
|
|
167
182
|
...(c.elementId !== undefined && c.elementId !== "" ? { elementId: c.elementId } : {}),
|
|
168
183
|
})),
|
|
169
184
|
...(metrics !== undefined ? { metrics } : {}),
|
|
185
|
+
...(r.producedAt !== undefined && r.producedAt !== "" ? { producedAt: r.producedAt } : {}),
|
|
186
|
+
...(time !== undefined ? { time } : {}),
|
|
170
187
|
};
|
|
171
188
|
}
|
|
172
189
|
|
|
@@ -303,6 +303,7 @@ test("agent-history panel renders the engine run list and a selected run's turns
|
|
|
303
303
|
content: [{ contentType: "TEXT", text: "did the thing" }],
|
|
304
304
|
toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool", arguments: {} }],
|
|
305
305
|
metrics: { inputTokens: 12, outputTokens: 4, reasoningTokenCount: 0, cacheCreationTokenCount: 0, cacheReadTokenCount: 0, durationMs: 900 },
|
|
306
|
+
producedAt: "2024-05-01T12:34:56.789Z",
|
|
306
307
|
},
|
|
307
308
|
],
|
|
308
309
|
};
|
|
@@ -334,6 +335,17 @@ test("agent-history panel renders the engine run list and a selected run's turns
|
|
|
334
335
|
assert(transcript != null, "the selected run's history rendered");
|
|
335
336
|
assert((transcript?.textContent ?? "").includes("did the thing"), "the turn text rendered");
|
|
336
337
|
assert((transcript?.textContent ?? "").includes("grep"), "the tool call rendered");
|
|
338
|
+
// #831 — each turn renders as a collapsed native <details> whose <summary> heading carries the
|
|
339
|
+
// UTC timestamp. Assert the browser twin mirrors the typed renderer so mount.js can't regress.
|
|
340
|
+
const turn = transcript?.querySelector(".cockpit-agent-turn");
|
|
341
|
+
assertEquals(turn?.tagName?.toLowerCase(), "details", "the turn is a <details>");
|
|
342
|
+
assertEquals(turn?.hasAttribute("open"), false, "collapsed by default: no open attribute");
|
|
343
|
+
const summary = turn?.querySelector(".cockpit-agent-turn-meta");
|
|
344
|
+
assertEquals(summary?.tagName?.toLowerCase(), "summary", "the heading is a <summary>");
|
|
345
|
+
const time = turn?.querySelector(".cockpit-agent-turn-time");
|
|
346
|
+
assertEquals(time?.tagName?.toLowerCase(), "time", "the timestamp is a <time>");
|
|
347
|
+
assertEquals(time?.textContent, "12:34:56", "the heading shows the UTC HH:MM:SS");
|
|
348
|
+
assertEquals(time?.getAttribute("datetime"), "2024-05-01T12:34:56.789Z", "the <time> carries the ISO instant");
|
|
337
349
|
} finally {
|
|
338
350
|
handle.dispose();
|
|
339
351
|
}
|
|
@@ -8,29 +8,56 @@
|
|
|
8
8
|
// guard moved downstream of progress classification) and likewise passes `recordRound=false`; an
|
|
9
9
|
// agent-raised arm with no prior round row omits the flag and must still record the round.
|
|
10
10
|
import { test } from "node:test";
|
|
11
|
-
import { assertEquals } from "#test-assert";
|
|
11
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
12
12
|
import handler from "../workers/persist-escalation/worker.ts";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// `failOn` forces the named table's `update` to throw, so a test can assert the escalation-insert +
|
|
15
|
+
// PR-stamp transaction rolls back as a unit (issue #829 — the insert-before-timestamp window).
|
|
16
|
+
function fakeApp(opts: { failOn?: string } = {}) {
|
|
15
17
|
const inserts: Record<string, unknown[]> = { rounds: [], escalations: [] };
|
|
16
18
|
const updates: Record<string, unknown[]> = { pull_requests: [] };
|
|
17
19
|
const rows: Record<string, Map<string, unknown>> = {};
|
|
20
|
+
// `apply` decides WHEN a staged write lands: outside a transaction it applies immediately; inside
|
|
21
|
+
// `open().tx()` it is buffered and flushed only on a clean return, so a throw discards it (rollback).
|
|
22
|
+
const makeTable = (name: string, apply: (fn: () => void) => void) => {
|
|
23
|
+
const store = (rows[name] ??= new Map());
|
|
24
|
+
return {
|
|
25
|
+
async get(key: string) {
|
|
26
|
+
return store.get(key);
|
|
27
|
+
},
|
|
28
|
+
async insert(row: unknown) {
|
|
29
|
+
const pk = name === "escalations" ? "id" : name === "rounds" ? "id" : "pr_key";
|
|
30
|
+
const id = name === "escalations" ? 42 : name === "rounds" ? 1 : (row as any).pr_key;
|
|
31
|
+
const stored = pk === "id" ? { id, ...(row as Record<string, unknown>) } : row;
|
|
32
|
+
apply(() => {
|
|
33
|
+
(inserts[name] ??= []).push(row);
|
|
34
|
+
store.set((stored as any)[pk], stored);
|
|
35
|
+
});
|
|
36
|
+
return id;
|
|
37
|
+
},
|
|
38
|
+
async update(key: string, patch: unknown) {
|
|
39
|
+
if (opts.failOn === name) throw new Error(`simulated ${name} update failure`);
|
|
40
|
+
apply(() => {
|
|
41
|
+
(updates[name] ??= []).push({ key, patch });
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
const applyNow = (fn: () => void) => fn();
|
|
18
47
|
const app = {
|
|
19
48
|
data: {
|
|
20
|
-
table(name: string, _key
|
|
21
|
-
|
|
49
|
+
table(name: string, _key?: string) {
|
|
50
|
+
return makeTable(name, applyNow);
|
|
51
|
+
},
|
|
52
|
+
open() {
|
|
22
53
|
return {
|
|
23
|
-
async
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
return name === "escalations" ? 42 : 1;
|
|
31
|
-
},
|
|
32
|
-
async update(key: string, patch: unknown) {
|
|
33
|
-
(updates[name] ??= []).push({ key, patch });
|
|
54
|
+
async tx<T>(fn: (t: unknown) => Promise<T>): Promise<T> {
|
|
55
|
+
const staged: Array<() => void> = [];
|
|
56
|
+
const stage = (fn2: () => void) => staged.push(fn2);
|
|
57
|
+
const handle = { table: (name: string, _key?: string) => makeTable(name, stage) };
|
|
58
|
+
const result = await fn(handle); // a throw here skips the flush below — the staged writes roll back
|
|
59
|
+
for (const s of staged) s();
|
|
60
|
+
return result;
|
|
34
61
|
},
|
|
35
62
|
};
|
|
36
63
|
},
|
|
@@ -50,6 +77,20 @@ test("stalled arm (recordRound=false) does not insert a duplicate rounds row", a
|
|
|
50
77
|
assertEquals((out as any).escalationId, 42);
|
|
51
78
|
});
|
|
52
79
|
|
|
80
|
+
// Red/green regression for the insert-before-timestamp race (issue #829, Copilot review). The
|
|
81
|
+
// escalated-PR self-heal snapshots the PR generation + open-escalation ids and fences its repair on
|
|
82
|
+
// them. If this worker inserted the fresh `open` escalation BEFORE stamping the PR's `updated_at`
|
|
83
|
+
// (two separate writes), a heal snapshot landing between them would see the new escalation under the
|
|
84
|
+
// OLD generation and could retire it, orphaning a live user task. The insert and the PR stamp are now
|
|
85
|
+
// one transaction, so they are all-or-nothing: a failed PR stamp must roll the escalation insert back
|
|
86
|
+
// — proving no `open` row is ever visible while the PR still carries the pre-escalation generation.
|
|
87
|
+
test("persist-escalation opens the escalation and stamps the PR atomically — a failed PR stamp rolls back the escalation insert (issue #829)", async () => {
|
|
88
|
+
const { app, inserts } = fakeApp({ failOn: "pull_requests" });
|
|
89
|
+
const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "needs input" } };
|
|
90
|
+
await assertRejects(() => handler(job as any, app as any));
|
|
91
|
+
assertEquals(inserts.escalations.length, 0, "the escalation insert rolls back with the failed PR stamp — no orphan open row under the old generation");
|
|
92
|
+
});
|
|
93
|
+
|
|
53
94
|
test("an agent-raised arm without the flag still records the round", async () => {
|
|
54
95
|
const { app, inserts } = fakeApp();
|
|
55
96
|
const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "needs input" } };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Drift guard for `PR_ESCALATION_PRODUCER_ELEMENTS` (issue #829).
|
|
2
|
+
//
|
|
3
|
+
// The escalated-PR self-heal (`pollUserTasks` in app/service.ts) treats an ACTIVE `pr.persist-escalation`
|
|
4
|
+
// PRODUCER element as positive evidence an escalation is being raised, so it must NOT retire the live
|
|
5
|
+
// escalation. That guard is only as correct as the element-id set it checks against — a NEW escalation
|
|
6
|
+
// arm added to either loop's BPMN, or a renamed one, would silently fall outside the set and re-open the
|
|
7
|
+
// race the fix closes. This test derives the truth from the deployed BPMN: every service task whose
|
|
8
|
+
// `zeebe:taskDefinition type="pr.persist-escalation"` in convergence-loop.bpmn / merge-loop.bpmn must be
|
|
9
|
+
// listed in the constant, and the constant must list nothing that isn't such a task. It fails loudly on
|
|
10
|
+
// either drift so the set stays the single source of truth.
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { assertEquals } from "#test-assert";
|
|
14
|
+
import { PR_ESCALATION_PRODUCER_ELEMENTS } from "./userTasks.ts";
|
|
15
|
+
|
|
16
|
+
/** Extract the `id`s of every `<bpmn:serviceTask>` whose `<zeebe:taskDefinition type="pr.persist-escalation"/>`
|
|
17
|
+
* from one BPMN file. Matches each service task element and keeps the ones carrying the producer job type. */
|
|
18
|
+
function persistEscalationTaskIds(bpmnPath: string): string[] {
|
|
19
|
+
const xml = readFileSync(bpmnPath, "utf8");
|
|
20
|
+
const ids: string[] = [];
|
|
21
|
+
const taskRe = /<bpmn:serviceTask\b[^>]*\bid="([^"]+)"[\s\S]*?<\/bpmn:serviceTask>/g;
|
|
22
|
+
for (let m = taskRe.exec(xml); m !== null; m = taskRe.exec(xml)) {
|
|
23
|
+
if (/<zeebe:taskDefinition\b[^>]*\btype="pr\.persist-escalation"/.test(m[0])) ids.push(m[1]);
|
|
24
|
+
}
|
|
25
|
+
return ids;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("PR_ESCALATION_PRODUCER_ELEMENTS matches every pr.persist-escalation task in both PR loops (issue #829)", () => {
|
|
29
|
+
const modelled = [
|
|
30
|
+
...persistEscalationTaskIds("resources/processes/convergence-loop.bpmn"),
|
|
31
|
+
...persistEscalationTaskIds("resources/processes/merge-loop.bpmn"),
|
|
32
|
+
].sort();
|
|
33
|
+
// Sanity: the BPMN really does carry producer tasks — a zero here would make the match vacuously pass.
|
|
34
|
+
assertEquals(modelled.length > 0, true, "expected at least one pr.persist-escalation task in the BPMN");
|
|
35
|
+
assertEquals(
|
|
36
|
+
[...PR_ESCALATION_PRODUCER_ELEMENTS].sort(),
|
|
37
|
+
modelled,
|
|
38
|
+
"PR_ESCALATION_PRODUCER_ELEMENTS drifted from the pr.persist-escalation tasks modelled in the BPMN — update the constant in app/userTasks.ts",
|
|
39
|
+
);
|
|
40
|
+
});
|