@nanobpm/nano-workforce 0.167.4 → 0.168.1
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/agentCompletion.ts +3 -0
- package/app/agentic/cockpit/cockpit-browser-bundle.test.ts +102 -0
- package/app/agentic/cockpit/mount.test.ts +162 -0
- package/app/pollEpicPhase.test.ts +64 -0
- package/app/pollUserTasks.test.ts +76 -2
- package/app/service.ts +202 -39
- package/app/userTasks.ts +12 -0
- package/package.json +3 -1
- package/pages/cockpit/cockpit.css +118 -0
- package/pages/cockpit/embed.html +1 -3
- package/pages/cockpit/generated/transcript-derive.js +260 -0
- package/pages/cockpit/generated/transcript-events.js +488 -0
- package/pages/cockpit/mount.js +85 -39
- package/pages/cockpit/standalone.html +7 -8
- package/scripts/build-cockpit-browser.ts +110 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.168.1](https://github.com/nanobpm/nano-workforce/compare/v0.168.0...v0.168.1) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **cockpit:** render the agentic transcript beneath the supply table ([#660](https://github.com/nanobpm/nano-workforce/issues/660)) ([#662](https://github.com/nanobpm/nano-workforce/issues/662)) ([ed2c041](https://github.com/nanobpm/nano-workforce/commit/ed2c041e0e4508e825324ed2b398bf64b5c66c37))
|
|
6
|
+
|
|
7
|
+
## [0.168.0](https://github.com/nanobpm/nano-workforce/compare/v0.167.4...v0.168.0) (2026-08-31)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* correlate callActivity child-cell escalations & epic phase via parent/root keys ([#633](https://github.com/nanobpm/nano-workforce/issues/633)) ([#657](https://github.com/nanobpm/nano-workforce/issues/657)) ([22b8674](https://github.com/nanobpm/nano-workforce/commit/22b86746548627921a1bc049b7999eeaa8fb5104)), closes [Magikcraft/nano-bpm#977](https://github.com/Magikcraft/nano-bpm/issues/977) [#464](https://github.com/nanobpm/nano-workforce/issues/464) [#591](https://github.com/nanobpm/nano-workforce/issues/591)
|
|
12
|
+
|
|
1
13
|
## [0.167.4](https://github.com/nanobpm/nano-workforce/compare/v0.167.3...v0.167.4) (2026-08-31)
|
|
2
14
|
|
|
3
15
|
### Build System
|
package/app/agentCompletion.ts
CHANGED
|
@@ -68,6 +68,8 @@ export const taskCompletions = (data: DataLayer) =>
|
|
|
68
68
|
* agent path is scoped to escalations — it can never complete an arbitrary internal user task. */
|
|
69
69
|
export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
|
|
70
70
|
"feature-escalation",
|
|
71
|
+
"escalation", // shared human-escalation cell (human-escalation.bpmn, ADR 0006 S4 #603/#633) — the same
|
|
72
|
+
// agent-answerable feature-escalation task, relocated into a callActivity child cell
|
|
71
73
|
"plan-review-decision",
|
|
72
74
|
"trial-merge-decision",
|
|
73
75
|
"wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
|
|
@@ -121,6 +123,7 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
121
123
|
* validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
|
|
122
124
|
const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
123
125
|
"feature-escalation": "feature-escalation",
|
|
126
|
+
"escalation": "feature-escalation", // shared human-escalation cell renders the SAME feature-escalation form (#603/#633)
|
|
124
127
|
"plan-review-decision": "plan-review-decision",
|
|
125
128
|
"trial-merge-decision": "trial-merge-decision",
|
|
126
129
|
"wait-answer": "pr-escalation",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// #660 — the browser transcript bundle is DERIVED from the typed core, and it RENDERS (never dumps raw
|
|
2
|
+
// `nwfTranscriptEvent` JSON).
|
|
3
|
+
//
|
|
4
|
+
// Two guarantees:
|
|
5
|
+
// 1. Drift guard — the committed `pages/cockpit/generated/*.js` is byte-identical to a fresh transpile
|
|
6
|
+
// of the `.ts` core, so the deployed browser render path can never silently drift from the typed,
|
|
7
|
+
// tested source (the exact failure mode #660 was: a hand-copy that lost the render path).
|
|
8
|
+
// 2. Behaviour — importing the GENERATED module the browser actually loads and rendering it into the
|
|
9
|
+
// DOM double proves a `nwfTranscriptEvent` chunk is surfaced as derived turns/tool/diff/permission
|
|
10
|
+
// cards, and NEVER verbatim.
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { assert, assertEquals } from "#test-assert";
|
|
16
|
+
import { cockpitBrowserBundle } from "../../../scripts/build-cockpit-browser.ts";
|
|
17
|
+
import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
|
|
18
|
+
import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
|
|
19
|
+
// The module under test is the GENERATED browser artifact the deployed cockpit imports — NOT the .ts
|
|
20
|
+
// source — so this exercises exactly the code path the browser runs.
|
|
21
|
+
import { renderDerivedTranscript } from "../../../pages/cockpit/generated/transcript-derive.js";
|
|
22
|
+
|
|
23
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
24
|
+
|
|
25
|
+
test("the committed browser bundle is byte-identical to its typed source (no drift surface)", () => {
|
|
26
|
+
for (const { out, content } of cockpitBrowserBundle()) {
|
|
27
|
+
const committed = readFileSync(resolve(repoRoot, out), "utf8");
|
|
28
|
+
assertEquals(
|
|
29
|
+
committed,
|
|
30
|
+
content,
|
|
31
|
+
`${out} is stale — regenerate with: node --experimental-strip-types scripts/build-cockpit-browser.ts`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const doc = new FakeDocument();
|
|
37
|
+
|
|
38
|
+
function env(kind: string, extra: Record<string, unknown> = {}): string {
|
|
39
|
+
return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A page whose chunks are `nwfTranscriptEvent` envelopes: messages, a tool call/result diff, a permission prompt. */
|
|
43
|
+
function report(): {
|
|
44
|
+
stream: string;
|
|
45
|
+
from: number;
|
|
46
|
+
gap: boolean;
|
|
47
|
+
nextOffset: number;
|
|
48
|
+
entries: Array<{ offset: number; chunk: string }>;
|
|
49
|
+
} {
|
|
50
|
+
const chunks = [
|
|
51
|
+
env("turn", { index: 0 }),
|
|
52
|
+
env("message", { role: "user", text: "please build it" }),
|
|
53
|
+
env("tool-call", { name: "edit", callId: "c1", args: { path: "a.txt", oldText: "one\n", newText: "two\n" } }),
|
|
54
|
+
env("tool-result", { callId: "c1", ok: true, content: "done" }),
|
|
55
|
+
env("message", { role: "assistant", text: "built it" }),
|
|
56
|
+
env("permission", {
|
|
57
|
+
phase: "request",
|
|
58
|
+
callId: "p1",
|
|
59
|
+
policy: "escalate",
|
|
60
|
+
title: "Run shell?",
|
|
61
|
+
toolName: "bash",
|
|
62
|
+
options: [
|
|
63
|
+
{ optionId: "ok", name: "Allow", kind: "allow-once" },
|
|
64
|
+
{ optionId: "no", name: "Deny", kind: "reject-once" },
|
|
65
|
+
],
|
|
66
|
+
}),
|
|
67
|
+
];
|
|
68
|
+
return { stream: "job:1", from: 0, gap: false, nextOffset: chunks.length, entries: chunks.map((chunk, offset) => ({ offset, chunk })) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test("regression: a nwfTranscriptEvent chunk is rendered as derived cards, NEVER surfaced verbatim", () => {
|
|
72
|
+
const host = new FakeElement("div");
|
|
73
|
+
renderDerivedTranscript(host as never, doc, report() as never);
|
|
74
|
+
// The rendered structured view exists…
|
|
75
|
+
assertEquals(host.byClass("cockpit-transcript-derived").length, 1);
|
|
76
|
+
// …and the raw envelope marker is nowhere in the rendered text (the #660 bug: raw JSON echoed).
|
|
77
|
+
assert(!host.text().includes(TRANSCRIPT_EVENT_MARKER), "rendered transcript must not contain the raw nwfTranscriptEvent marker");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("feature: messages coalesce into one turn, tool/diff card renders, permission prompt renders", () => {
|
|
81
|
+
const host = new FakeElement("div");
|
|
82
|
+
renderDerivedTranscript(host as never, doc, report() as never);
|
|
83
|
+
|
|
84
|
+
// Message coalescing: both the user and assistant messages fold under a SINGLE derived turn section.
|
|
85
|
+
const turns = host.byClass("cockpit-transcript-turn");
|
|
86
|
+
assertEquals(turns.length, 1);
|
|
87
|
+
const roles = turns[0]?.byClass("cockpit-transcript-message").map((n) => n.getAttribute("data-role")) ?? [];
|
|
88
|
+
assertEquals(roles, ["user", "assistant"]);
|
|
89
|
+
|
|
90
|
+
// Tool card with a synthesized diff (structured edit args → add/del lines).
|
|
91
|
+
const tool = host.byData("tool", "edit")[0];
|
|
92
|
+
assert(tool !== undefined, "the tool card is rendered");
|
|
93
|
+
assertEquals(tool?.getAttribute("data-tool-kind"), "diff");
|
|
94
|
+
const diffKinds = host.byClass("cockpit-transcript-diff-line").map((n) => n.getAttribute("data-diff-line"));
|
|
95
|
+
assert(diffKinds.includes("add") && diffKinds.includes("del"), "the diff shows add + del lines");
|
|
96
|
+
|
|
97
|
+
// Permission prompt with interactive Allow/Deny options.
|
|
98
|
+
const perm = host.byData("permission", "request")[0];
|
|
99
|
+
assert(perm !== undefined, "the permission prompt is rendered");
|
|
100
|
+
assertEquals(perm?.getAttribute("data-status"), "pending");
|
|
101
|
+
assertEquals(host.byClass("cockpit-transcript-permission-option").length, 2);
|
|
102
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// #660 — the DEPLOYED browser adapter (pages/cockpit/mount.js) renders the transcript for BOTH a live
|
|
2
|
+
// drill and a past-session replay, and NEVER surfaces a raw `nwfTranscriptEvent` chunk verbatim.
|
|
3
|
+
//
|
|
4
|
+
// This drives mount.js end-to-end on Node against a real (linkedom) DOM, a stub relay WebSocket, and a
|
|
5
|
+
// stub `fetch`, so it exercises the actual live-drill sink wiring and the replay fetch→render path — the
|
|
6
|
+
// two seams that used to write relay chunks straight to xterm. It also asserts the rendered transcript
|
|
7
|
+
// region sits directly beneath the Workers — supply table.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
|
+
import { encodeFrame } from "@nanobpm/agentic/protocol";
|
|
11
|
+
import { parseHTML } from "linkedom";
|
|
12
|
+
import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
|
|
13
|
+
|
|
14
|
+
function envChunk(kind: string, extra: Record<string, unknown> = {}): string {
|
|
15
|
+
return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A stub browser WebSocket that records instances and lets a test drive open + inbound frames by hand. */
|
|
19
|
+
class StubWebSocket {
|
|
20
|
+
static readonly instances: StubWebSocket[] = [];
|
|
21
|
+
binaryType = "";
|
|
22
|
+
readonly url: string;
|
|
23
|
+
readonly #listeners = new Map<string, Array<(event: unknown) => void>>();
|
|
24
|
+
constructor(url: string) {
|
|
25
|
+
this.url = url;
|
|
26
|
+
StubWebSocket.instances.push(this);
|
|
27
|
+
}
|
|
28
|
+
addEventListener(type: string, handler: (event: unknown) => void): void {
|
|
29
|
+
const list = this.#listeners.get(type) ?? [];
|
|
30
|
+
list.push(handler);
|
|
31
|
+
this.#listeners.set(type, list);
|
|
32
|
+
}
|
|
33
|
+
send(): void {}
|
|
34
|
+
close(): void {}
|
|
35
|
+
fireOpen(): void {
|
|
36
|
+
for (const h of this.#listeners.get("open") ?? []) h({});
|
|
37
|
+
}
|
|
38
|
+
/** Deliver one relay frame (as the browser would: an ArrayBuffer message event). */
|
|
39
|
+
deliver(frame: unknown): void {
|
|
40
|
+
const bytes = encodeFrame(frame as never);
|
|
41
|
+
for (const h of this.#listeners.get("message") ?? []) h({ data: bytes.buffer });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Install a linkedom DOM + stub WebSocket/fetch as globals mount.js reads; returns a cleanup fn. */
|
|
46
|
+
function installEnv(fetchImpl: (url: string) => Promise<unknown>): () => void {
|
|
47
|
+
const { window, document } = parseHTML("<!doctype html><html><body><main id='root'></main></body></html>");
|
|
48
|
+
const g = globalThis as Record<string, unknown>;
|
|
49
|
+
const saved = {
|
|
50
|
+
window: g.window,
|
|
51
|
+
document: g.document,
|
|
52
|
+
location: g.location,
|
|
53
|
+
WebSocket: g.WebSocket,
|
|
54
|
+
fetch: g.fetch,
|
|
55
|
+
};
|
|
56
|
+
g.window = window;
|
|
57
|
+
g.document = document;
|
|
58
|
+
g.location = { hash: "", href: "http://app.test/cockpit/", pathname: "/cockpit/", search: "" };
|
|
59
|
+
g.WebSocket = StubWebSocket;
|
|
60
|
+
g.fetch = (url: unknown) => fetchImpl(String(url));
|
|
61
|
+
StubWebSocket.instances.length = 0;
|
|
62
|
+
return () => {
|
|
63
|
+
g.window = saved.window;
|
|
64
|
+
g.document = saved.document;
|
|
65
|
+
g.location = saved.location;
|
|
66
|
+
g.WebSocket = saved.WebSocket;
|
|
67
|
+
g.fetch = saved.fetch;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SUPPLY = { leaves: [], correlations: [] };
|
|
72
|
+
|
|
73
|
+
/** A fetch stub answering the supply poll, the past-sessions list, and a single-stream replay. */
|
|
74
|
+
function fetchStub(replay?: unknown) {
|
|
75
|
+
return (url: string): Promise<unknown> => {
|
|
76
|
+
const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
|
|
77
|
+
if (url.includes("/supply")) return ok(SUPPLY);
|
|
78
|
+
if (replay !== undefined && /\/transcripts\/[^/]+$/.test(url)) return ok(replay);
|
|
79
|
+
if (url.includes("/transcripts")) return ok({ sessions: [] });
|
|
80
|
+
return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const OPTS = {
|
|
85
|
+
reportUrl: "http://app.test/app/api/agentic/supply",
|
|
86
|
+
transcriptsUrl: "http://app.test/app/api/agentic/transcripts",
|
|
87
|
+
relayUrl: "ws://app.test/agentic",
|
|
88
|
+
refreshMs: 1_000_000, // effectively disable the self-scheduling poll; we dispose() at the end.
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
test("the rendered transcript region sits directly beneath the Workers — supply table", async () => {
|
|
92
|
+
const restore = installEnv(fetchStub());
|
|
93
|
+
try {
|
|
94
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
95
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
96
|
+
const shell = document.querySelector(".cockpit-shell");
|
|
97
|
+
const order = [...(shell?.children ?? [])].map((c: { className: string }) => c.className);
|
|
98
|
+
assertEquals(order, ["cockpit-supply-region", "cockpit-terminal", "cockpit-past-region"]);
|
|
99
|
+
handle.dispose();
|
|
100
|
+
} finally {
|
|
101
|
+
restore();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("live drill renders the transcript — a nwfTranscriptEvent chunk is never surfaced verbatim", async () => {
|
|
106
|
+
const restore = installEnv(fetchStub());
|
|
107
|
+
try {
|
|
108
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
109
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
110
|
+
handle.drill("job:live");
|
|
111
|
+
const socket = StubWebSocket.instances[0];
|
|
112
|
+
assert(socket !== undefined, "a relay socket was opened for the drill");
|
|
113
|
+
socket.fireOpen();
|
|
114
|
+
socket.deliver({ lane: "control", family: "relay", seq: 0, payload: { op: "subscribed", stream: "job:live", gap: false, nextOffset: 0 } });
|
|
115
|
+
socket.deliver({
|
|
116
|
+
lane: "bulk",
|
|
117
|
+
family: "relay",
|
|
118
|
+
seq: 1,
|
|
119
|
+
payload: { stream: "job:live", offset: 0, chunk: envChunk("message", { role: "assistant", text: "hello from the agent" }) },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const host = document.querySelector('[data-terminal="host"]');
|
|
123
|
+
const rendered = host?.querySelector(".cockpit-transcript-derived");
|
|
124
|
+
assert(rendered != null, "the derived transcript is rendered into the terminal host");
|
|
125
|
+
assert((host?.textContent ?? "").includes("hello from the agent"), "the message text is rendered");
|
|
126
|
+
assert(!(host?.textContent ?? "").includes(TRANSCRIPT_EVENT_MARKER), "the raw nwfTranscriptEvent marker is never shown");
|
|
127
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
|
|
128
|
+
handle.dispose();
|
|
129
|
+
} finally {
|
|
130
|
+
restore();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("replay renders a past session's transcript — never a raw nwfTranscriptEvent dump", async () => {
|
|
135
|
+
const replay = {
|
|
136
|
+
stream: "job:past",
|
|
137
|
+
from: 0,
|
|
138
|
+
gap: false,
|
|
139
|
+
nextOffset: 3,
|
|
140
|
+
entries: [
|
|
141
|
+
{ offset: 0, chunk: envChunk("message", { role: "user", text: "kick off" }) },
|
|
142
|
+
{ offset: 1, chunk: envChunk("tool-call", { name: "grep", callId: "c1" }) },
|
|
143
|
+
{ offset: 2, chunk: envChunk("tool-result", { callId: "c1", ok: true, content: "match" }) },
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
const restore = installEnv(fetchStub(replay));
|
|
147
|
+
try {
|
|
148
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
149
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
150
|
+
await handle.replay("job:past");
|
|
151
|
+
|
|
152
|
+
const host = document.querySelector('[data-terminal="host"]');
|
|
153
|
+
assert(host?.querySelector(".cockpit-transcript-derived") != null, "the derived transcript is rendered on replay");
|
|
154
|
+
assert((host?.textContent ?? "").includes("kick off"), "the message text is rendered");
|
|
155
|
+
assert(host?.querySelector('[data-tool="grep"]') != null, "the tool card is rendered");
|
|
156
|
+
assert(!(host?.textContent ?? "").includes(TRANSCRIPT_EVENT_MARKER), "the raw nwfTranscriptEvent marker is never shown");
|
|
157
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
|
|
158
|
+
handle.dispose();
|
|
159
|
+
} finally {
|
|
160
|
+
restore();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
@@ -157,3 +157,67 @@ test("pollEpicPhase skips a live epic that has no engine instance yet", async ()
|
|
|
157
157
|
assertEquals(called, false);
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
/** Stub `globalThis.fetch` so `pollEpicPhase`'s callActivity hierarchy walk (issue #633) reads its
|
|
162
|
+
* descendant instances from `childrenByParent` (keyed on the queried `parentProcessInstanceKey`), and
|
|
163
|
+
* 404s any other path so a stray call is loud. Returns a restore fn. */
|
|
164
|
+
function stubProcessInstanceSearch(childrenByParent: Record<string, string[]>): () => void {
|
|
165
|
+
const orig = globalThis.fetch;
|
|
166
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
|
|
167
|
+
globalThis.fetch = (async (url: string | URL, init?: any) => {
|
|
168
|
+
const u = String(url);
|
|
169
|
+
if (!u.endsWith("/process-instances/search")) return new Response("not found", { status: 404 });
|
|
170
|
+
const body = JSON.parse(init?.body ?? "{}");
|
|
171
|
+
const parent: string = body?.filter?.parentProcessInstanceKey ?? "";
|
|
172
|
+
const items = (childrenByParent[parent] ?? []).map((k) => ({ processInstanceKey: k }));
|
|
173
|
+
return new Response(JSON.stringify({ items }), {
|
|
174
|
+
status: 200,
|
|
175
|
+
headers: { "content-type": "application/json" },
|
|
176
|
+
});
|
|
177
|
+
}) as typeof fetch;
|
|
178
|
+
return () => {
|
|
179
|
+
globalThis.fetch = orig;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
test("pollEpicPhase derives from an element INSIDE a callActivity CHILD cell via parent/root traversal (issue #633)", async () => {
|
|
184
|
+
// ADR 0006 S4 (#603/#633): once a wave/slice runs as a callActivity CHILD cell, the furthest-reached
|
|
185
|
+
// live token can sit INSIDE that child instance ("child-pi"), not on the parent plan-fanout spine
|
|
186
|
+
// ("pi-1"). The parent instance shows only a settled (COMPLETED) `record-plan`, so a parent-ONLY read
|
|
187
|
+
// would leave the phase at PLANNING; walking the hierarchy (parent → child) surfaces the child's ACTIVE
|
|
188
|
+
// `review-plan`, advancing the phase to Reviewing. The pure `deriveEpicPhaseLive` is unchanged — only
|
|
189
|
+
// its INPUT is widened to the whole instance hierarchy.
|
|
190
|
+
await withData(async (data) => {
|
|
191
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.PLANNING });
|
|
192
|
+
const engine = {
|
|
193
|
+
searchElementInstances: async ({ processInstanceKey }: { processInstanceKey: string }) =>
|
|
194
|
+
processInstanceKey === "child-pi"
|
|
195
|
+
? [{ elementInstanceKey: "c1", processInstanceKey: "child-pi", elementId: "review-plan", state: "ACTIVE" }]
|
|
196
|
+
: [{ elementInstanceKey: "e1", processInstanceKey: "pi-1", elementId: "record-plan", state: "COMPLETED" }],
|
|
197
|
+
};
|
|
198
|
+
const restore = stubProcessInstanceSearch({ "pi-1": ["child-pi"], "child-pi": [] });
|
|
199
|
+
try {
|
|
200
|
+
await pollEpicPhase(data, engine as never, { restAddress: "http://engine.test/v2" });
|
|
201
|
+
} finally {
|
|
202
|
+
restore();
|
|
203
|
+
}
|
|
204
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.REVIEWING);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("pollEpicPhase without a raw-REST surface reads the parent instance ALONE (no traversal, pre-#633 behaviour)", async () => {
|
|
209
|
+
// The typed seam cannot enumerate children, so with no `engineRest` the walk degrades to the parent
|
|
210
|
+
// plan-fanout instance only — a child-cell token is invisible and the phase stays at PLANNING. This
|
|
211
|
+
// pins the two-arg (no-REST) call the unit path and degraded hosts use.
|
|
212
|
+
await withData(async (data) => {
|
|
213
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.PLANNING });
|
|
214
|
+
const engine = {
|
|
215
|
+
searchElementInstances: async ({ processInstanceKey }: { processInstanceKey: string }) =>
|
|
216
|
+
processInstanceKey === "child-pi"
|
|
217
|
+
? [{ elementInstanceKey: "c1", processInstanceKey: "child-pi", elementId: "review-plan", state: "ACTIVE" }]
|
|
218
|
+
: [{ elementInstanceKey: "e1", processInstanceKey: "pi-1", elementId: "record-plan", state: "COMPLETED" }],
|
|
219
|
+
};
|
|
220
|
+
await pollEpicPhase(data, engine as never);
|
|
221
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.PLANNING);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -370,7 +370,7 @@ test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row",
|
|
|
370
370
|
|
|
371
371
|
/** A single task as the raw Camunda-8 `/v2/user-tasks/search` reports it — carries `processInstanceKey`
|
|
372
372
|
* (the typed seam omits it) so the sweep can map a task back to its subject for enrichment. */
|
|
373
|
-
type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; state?: string; formKey?: string | number | null };
|
|
373
|
+
type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; rootProcessInstanceKey?: string | number | null; state?: string; formKey?: string | number | null };
|
|
374
374
|
|
|
375
375
|
/** Stub `globalThis.fetch` so `pollUserTasks`' engine-first sweep reads its open tasks from `tasks`.
|
|
376
376
|
* Honours the `page.from`/`page.limit` pagination the sweep drives, and 404s any other path so a stray
|
|
@@ -444,7 +444,6 @@ test("pollUserTasks (engine-first): orphaned plan-review and PR-wait escalations
|
|
|
444
444
|
});
|
|
445
445
|
|
|
446
446
|
test("pollUserTasks (engine-first): a TRACKED task is still fully enriched from its subject row (no regression)", async () => {
|
|
447
|
-
// Enrich, don't gate: when a subject row DOES reference the task's instance, title/url/question come
|
|
448
447
|
// from it exactly as the per-subject scan produced — the sweep maps by `processInstanceKey`.
|
|
449
448
|
const { data, stores } = memData({
|
|
450
449
|
feature_runs: [
|
|
@@ -479,6 +478,40 @@ test("pollUserTasks (engine-first): a TRACKED task is still fully enriched from
|
|
|
479
478
|
assertEquals(byKey["ut-plan"].question, "scope too broad");
|
|
480
479
|
});
|
|
481
480
|
|
|
481
|
+
test("pollUserTasks (engine-first): a child-cell escalation correlates to its PARENT run via rootProcessInstanceKey (issue #633)", async () => {
|
|
482
|
+
// ADR 0006 S4 (#603/#633): once a slice's implement step runs as a callActivity CHILD cell, the
|
|
483
|
+
// agent-stuck escalation parks on the shared `human-escalation` cell's `escalation` element inside a
|
|
484
|
+
// CHILD instance ("child-pi") whose key NO subject row tracks — the owning feature run is tracked under
|
|
485
|
+
// the PARENT/root instance ("fp-10") the engine reports as `rootProcessInstanceKey`. The poller must
|
|
486
|
+
// correlate the child-instance task back to the parent run (subject + question + kind), not strand it
|
|
487
|
+
// as an orphan keyed to the raw child instance.
|
|
488
|
+
const { data, stores } = memData({
|
|
489
|
+
feature_runs: [
|
|
490
|
+
{ feature_key: "o/r#10", status: "escalated", process_key: "fp-10", issue_url: "https://github.com/o/r/issues/10", title: "Add the framework selector", delivery_label: null },
|
|
491
|
+
],
|
|
492
|
+
feature_escalations: [
|
|
493
|
+
{ id: 1, feature_key: "o/r#10", question: "which framework?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
|
|
494
|
+
],
|
|
495
|
+
});
|
|
496
|
+
const restore = stubUserTaskSearch([
|
|
497
|
+
{ userTaskKey: "ut-child", elementId: "escalation", processInstanceKey: "child-pi", rootProcessInstanceKey: "fp-10", state: "CREATED" },
|
|
498
|
+
]);
|
|
499
|
+
try {
|
|
500
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
501
|
+
} finally {
|
|
502
|
+
restore();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
506
|
+
assertEquals(Object.keys(byKey), ["ut-child"]);
|
|
507
|
+
assertEquals(byKey["ut-child"].element_id, "escalation");
|
|
508
|
+
assertEquals(byKey["ut-child"].kind_label, "Feature escalation");
|
|
509
|
+
assertEquals(byKey["ut-child"].subject_type, "feature");
|
|
510
|
+
assertEquals(byKey["ut-child"].subject_key, "o/r#10"); // correlated to the PARENT run, not the child instance
|
|
511
|
+
assertEquals(byKey["ut-child"].subject_title, "Add the framework selector");
|
|
512
|
+
assertEquals(byKey["ut-child"].question, "which framework?"); // same feature_escalations log via the parent subject
|
|
513
|
+
});
|
|
514
|
+
|
|
482
515
|
test("pollUserTasks (engine-first): never leaks a non-escalation element nor a non-CREATED task", async () => {
|
|
483
516
|
// The `USER_TASK_KIND_LABELS` gate keeps an arbitrary internal user task out of the inbox, and the
|
|
484
517
|
// defensive state re-filter drops a lagging COMPLETED/CANCELED read (a dead affordance, #294) even if
|
|
@@ -825,6 +858,47 @@ test("pollUserTasks (engine-first): does NOT heal a JUST-escalated run inside th
|
|
|
825
858
|
assertEquals(byKey["o/r#old"].status, "running", "a genuinely-stranded (old) run is still healed");
|
|
826
859
|
});
|
|
827
860
|
|
|
861
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated run parked in a callActivity CHILD instance the sweep missed (issue #633)", async () => {
|
|
862
|
+
// A run's escalation can park inside a callActivity CHILD instance on the shared `human-escalation`
|
|
863
|
+
// cell's `escalation` element (ADR 0006 S4, #633), correlated back to the parent run via the root key.
|
|
864
|
+
// When this pass's engine-first sweep is truncated/unavailable, that child task is absent from `desired`,
|
|
865
|
+
// so the run falls through to the per-instance confirmation. Confirming ONLY the parent `process_key`
|
|
866
|
+
// (whose own open tasks are empty — the escalation lives in the CHILD instance) would read "no
|
|
867
|
+
// escalation open" and wrongly flip a genuinely-parked run back to `running`. The confirmation must
|
|
868
|
+
// include the callActivity descendants when the raw-REST surface is available.
|
|
869
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString(); // past the heal grace window
|
|
870
|
+
const { data, stores } = memData({
|
|
871
|
+
feature_runs: [
|
|
872
|
+
{ feature_key: "o/r#child", status: "escalated", process_key: "fp-parent", updated_at: stale, issue_url: null, title: "parked in child cell", delivery_label: null },
|
|
873
|
+
],
|
|
874
|
+
});
|
|
875
|
+
// The sweep is unavailable (returns empty), so fp-parent is NOT confirmed parked via `desired`; the
|
|
876
|
+
// descendant walk over `/process-instances/search` surfaces the child instance carrying the escalation.
|
|
877
|
+
const orig = globalThis.fetch;
|
|
878
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surfaces
|
|
879
|
+
globalThis.fetch = (async (url: string | URL, init?: any) => {
|
|
880
|
+
const u = String(url);
|
|
881
|
+
if (u.endsWith("/user-tasks/search")) {
|
|
882
|
+
return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
883
|
+
}
|
|
884
|
+
if (u.endsWith("/process-instances/search")) {
|
|
885
|
+
const parent = JSON.parse(init?.body ?? "{}")?.filter?.parentProcessInstanceKey;
|
|
886
|
+
const items = parent === "fp-parent" ? [{ processInstanceKey: "child-1" }] : [];
|
|
887
|
+
return new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } });
|
|
888
|
+
}
|
|
889
|
+
return new Response("not found", { status: 404 });
|
|
890
|
+
}) as typeof fetch;
|
|
891
|
+
// The escalation is parked in the CHILD instance on the `escalation` element, not on the parent.
|
|
892
|
+
const engine = fakeEngine({ "fp-parent": [], "child-1": [{ userTaskKey: "ut-child", elementId: "escalation" }] });
|
|
893
|
+
try {
|
|
894
|
+
await pollUserTasks(data, engine, REST);
|
|
895
|
+
} finally {
|
|
896
|
+
globalThis.fetch = orig;
|
|
897
|
+
}
|
|
898
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
899
|
+
assertEquals(byKey["o/r#child"].status, "escalated", "a run parked in a callActivity child cell survives when the sweep missed it");
|
|
900
|
+
});
|
|
901
|
+
|
|
828
902
|
test("pollUserTasks (typed-seam fallback): self-heals an escalated run with no open feature-escalation task (issue #642)", async () => {
|
|
829
903
|
// The reduced-capability path scans FEATURE_ACTIVE_STATUSES instances (incl. `escalated`) directly,
|
|
830
904
|
// so the per-instance open-task read is just as authoritative for the self-heal.
|