@nanobpm/nano-workforce 0.183.1 → 0.184.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/agent-history.test.ts +165 -0
- package/app/agentic/agent-history.ts +211 -0
- package/app/agentic/claim-registry.test.ts +3 -2
- package/app/agentic/claim-registry.ts +8 -6
- package/app/agentic/cockpit/agent-history-render.test.ts +85 -0
- package/app/agentic/cockpit/agent-history-render.ts +168 -0
- package/app/agentic/cockpit/agent-history-view.test.ts +104 -0
- package/app/agentic/cockpit/agent-history-view.ts +186 -0
- package/app/agentic/cockpit/index.ts +21 -0
- package/app/agentic/cockpit/mount.test.ts +133 -1
- package/app/agentic/cockpit/supply-boot-agent-history.test.ts +144 -0
- package/app/agentic/cockpit/supply-boot.ts +134 -1
- package/app/agentic/cockpit/supply-view.ts +3 -3
- package/app/agentic/cockpit/transcript-view.ts +1 -1
- package/app/agentic/correlation-store.test.ts +14 -10
- package/app/agentic/correlation-store.ts +4 -3
- package/app/agentic/correlation.test.ts +24 -16
- package/app/agentic/correlation.ts +25 -12
- package/app/agentic/families/claim.family.test.ts +2 -1
- package/app/agentic/families/relay.family.test.ts +74 -73
- package/app/agentic/families/relay.family.ts +24 -21
- package/app/agentic/transcript-read.test.ts +108 -17
- package/app/agentic/transcript-read.ts +41 -7
- package/app/contracts.ts +10 -2
- package/app/mcpToolSurface.ts +8 -1
- package/db/migrations/101_agentic_history_read_expand.sql +34 -0
- package/openapi.yaml +360 -0
- package/operations/agentHistoryEndpoints.test.ts +106 -0
- package/operations/getAgentInstanceHistory.ts +37 -0
- package/operations/getAgenticSupply.test.ts +41 -2
- package/operations/getAgenticSupply.ts +7 -5
- package/operations/getAgenticTranscript.test.ts +5 -4
- package/operations/listAgentInstances.ts +42 -0
- package/operations/listAgenticTranscripts.test.ts +10 -9
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +104 -0
- package/pages/cockpit/mount.js +286 -3
- package/test/agentic-e2e.test.ts +4 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Unit tests for the SUPPLY cockpit boot layer's engine-native AGENT-HISTORY panel (issue #745/#747).
|
|
2
|
+
//
|
|
3
|
+
// The testable heart of the consumer half: the cockpit renders a settled agent-history list (sourced
|
|
4
|
+
// from engine searchAgentInstances) beside the live supply + relay past-sessions panels; selecting a
|
|
5
|
+
// run renders its ordered conversation turns + metrics (sourced from engine searchAgentInstanceHistory)
|
|
6
|
+
// keyed by agentInstanceKey — never a relay stream id. No browser, no engine, no socket.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { FakeDocument, FakeElement, FakeSocket } from "../../../test/agentic-cockpit-doubles.ts";
|
|
10
|
+
import type {
|
|
11
|
+
AgentHistory as AgentHistoryReport,
|
|
12
|
+
AgentInstanceList as AgentInstanceListReport,
|
|
13
|
+
} from "../../../nano-generated/api-io.d.ts";
|
|
14
|
+
import { bootSupplyCockpit, type SupplyCockpitEnv } from "./supply-boot.ts";
|
|
15
|
+
import type { SupplyReport } from "./supply-view.ts";
|
|
16
|
+
|
|
17
|
+
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
18
|
+
|
|
19
|
+
const supply: SupplyReport = { count: 0, workers: [], leaves: [] };
|
|
20
|
+
|
|
21
|
+
const instances: AgentInstanceListReport = {
|
|
22
|
+
count: 2,
|
|
23
|
+
instances: [
|
|
24
|
+
{ agentInstanceKey: "ai-old", status: "COMPLETED", processInstanceKey: "pi-1", elementId: "implement-task", completionDate: "2024-01-01T00:00:00Z", metrics: { inputTokens: 100, outputTokens: 20, modelCalls: 2, toolCalls: 1 } },
|
|
25
|
+
{ agentInstanceKey: "ai-new", status: "IDLE", processInstanceKey: "pi-2", lastUpdatedDate: "2024-02-01T00:00:00Z" },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const history: AgentHistoryReport = {
|
|
30
|
+
agentInstanceKey: "ai-old",
|
|
31
|
+
count: 1,
|
|
32
|
+
instance: instances.instances[0],
|
|
33
|
+
records: [
|
|
34
|
+
{ historyItemKey: "h-0", agentInstanceKey: "ai-old", loopIteration: 0, role: "ASSISTANT", commitStatus: "COMMITTED", content: [{ contentType: "TEXT", text: "did the thing" }], toolCalls: [] },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
interface Rig {
|
|
39
|
+
readonly env: SupplyCockpitEnv;
|
|
40
|
+
readonly host: FakeElement;
|
|
41
|
+
requestedHistoryKey: string | undefined;
|
|
42
|
+
errors: unknown[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rig(withAgentHistory = true): Rig {
|
|
46
|
+
const host = new FakeElement("body");
|
|
47
|
+
const state: Rig = {
|
|
48
|
+
host,
|
|
49
|
+
requestedHistoryKey: undefined,
|
|
50
|
+
errors: [],
|
|
51
|
+
env: {
|
|
52
|
+
host,
|
|
53
|
+
doc: new FakeDocument(),
|
|
54
|
+
fetchSupply: () => Promise.resolve(supply),
|
|
55
|
+
...(withAgentHistory
|
|
56
|
+
? {
|
|
57
|
+
fetchAgentInstances: () => Promise.resolve(instances),
|
|
58
|
+
fetchAgentHistory: (agentInstanceKey: string) => {
|
|
59
|
+
state.requestedHistoryKey = agentInstanceKey;
|
|
60
|
+
return Promise.resolve(history);
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
connectRelay: () => new FakeSocket(),
|
|
65
|
+
createTerminal: (terminalHost) => {
|
|
66
|
+
terminalHost.appendChild(new FakeElement("pre"));
|
|
67
|
+
return { write: () => {}, dispose: () => {} };
|
|
68
|
+
},
|
|
69
|
+
setTimer: () => 0,
|
|
70
|
+
clearTimer: () => {},
|
|
71
|
+
onError: (err) => state.errors.push(err),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("refresh renders the engine agent-history list, sorted newest-first", async () => {
|
|
78
|
+
const r = rig();
|
|
79
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
80
|
+
await cockpit.refresh();
|
|
81
|
+
await flush();
|
|
82
|
+
const rows = r.host.byClass("cockpit-agent-session");
|
|
83
|
+
assert.equal(rows.length, 2, "one row per engine agent instance");
|
|
84
|
+
assert.equal(rows[0]?.getAttribute("data-agent-instance-key"), "ai-new", "newest-updated run first");
|
|
85
|
+
assert.equal(r.host.byData("summary", "agent-history").length, 1);
|
|
86
|
+
assert.deepEqual(r.errors, []);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("selecting a run renders its engine history keyed by agentInstanceKey", async () => {
|
|
90
|
+
const r = rig();
|
|
91
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
92
|
+
await cockpit.refresh();
|
|
93
|
+
await flush();
|
|
94
|
+
|
|
95
|
+
await cockpit.viewAgentHistory("ai-old");
|
|
96
|
+
await flush();
|
|
97
|
+
assert.equal(r.requestedHistoryKey, "ai-old", "history fetched by agent-instance key, not a stream id");
|
|
98
|
+
assert.equal(cockpit.currentAgentInstanceKey, "ai-old");
|
|
99
|
+
const transcript = r.host.byClass("cockpit-agent-transcript")[0];
|
|
100
|
+
assert.equal(transcript?.getAttribute("data-agent-instance-key"), "ai-old");
|
|
101
|
+
assert.equal(r.host.byClass("cockpit-agent-turn-text")[0]?.text(), "did the thing");
|
|
102
|
+
assert.deepEqual(r.errors, []);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("clicking a run button drives viewAgentHistory", async () => {
|
|
106
|
+
const r = rig();
|
|
107
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
108
|
+
await cockpit.refresh();
|
|
109
|
+
await flush();
|
|
110
|
+
const button = r.host.byClass("cockpit-agent-select").find((b) => b.getAttribute("data-agent-instance-key") === "ai-old");
|
|
111
|
+
button?.dispatch("click");
|
|
112
|
+
await flush();
|
|
113
|
+
assert.equal(cockpit.currentAgentInstanceKey, "ai-old");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the embedded shell renders the terminal directly beneath the supply list, matching mount.js", async () => {
|
|
117
|
+
const r = rig();
|
|
118
|
+
bootSupplyCockpit(r.env);
|
|
119
|
+
const shell = r.host.byClass("cockpit-shell")[0];
|
|
120
|
+
const order = (shell?.children ?? []).map((c) => c.className);
|
|
121
|
+
assert.deepEqual(order, [
|
|
122
|
+
"cockpit-supply-region",
|
|
123
|
+
"cockpit-terminal",
|
|
124
|
+
"cockpit-agent-region",
|
|
125
|
+
"cockpit-agent-detail-region",
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("no agent-history panel is rendered when the engine read endpoints are unwired", async () => {
|
|
130
|
+
const r = rig(false);
|
|
131
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
132
|
+
await cockpit.refresh();
|
|
133
|
+
await flush();
|
|
134
|
+
assert.equal(r.host.byClass("cockpit-agent-region").length, 0);
|
|
135
|
+
assert.equal(cockpit.currentAgentInstanceKey, undefined);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("fetchAgentInstances without fetchAgentHistory fails loudly (matched-pair guard)", () => {
|
|
139
|
+
const r = rig(false);
|
|
140
|
+
assert.throws(
|
|
141
|
+
() => bootSupplyCockpit({ ...r.env, fetchAgentInstances: () => Promise.resolve(instances) }),
|
|
142
|
+
/fetchAgentInstances and fetchAgentHistory must be provided together/,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
@@ -28,6 +28,13 @@ import {
|
|
|
28
28
|
TerminalSession,
|
|
29
29
|
type TerminalSink,
|
|
30
30
|
} from "@nanobpm/agentic/cockpit";
|
|
31
|
+
import { renderAgentHistory, renderAgentSessions } from "./agent-history-render.ts";
|
|
32
|
+
import {
|
|
33
|
+
type AgentHistoryReport,
|
|
34
|
+
type AgentInstanceListReport,
|
|
35
|
+
agentHistoryView,
|
|
36
|
+
agentSessionsView,
|
|
37
|
+
} from "./agent-history-view.ts";
|
|
31
38
|
import type { CockpitRoute } from "./cockpit-route.ts";
|
|
32
39
|
import { renderSupply } from "./supply-render.ts";
|
|
33
40
|
import type { SupplyReport, SupplyView } from "./supply-view.ts";
|
|
@@ -69,6 +76,23 @@ export interface SupplyCockpitEnv {
|
|
|
69
76
|
* Required for the "past sessions" replay to work; must be provided together with {@link fetchTranscripts}.
|
|
70
77
|
*/
|
|
71
78
|
readonly fetchTranscript?: (stream: string, from?: number) => Promise<TranscriptDataReport>;
|
|
79
|
+
/**
|
|
80
|
+
* Fetches the engine-native AgentInstance list (`GET /agentic/agent-instances`, served from
|
|
81
|
+
* `@nanobpm/urban`'s EngineClient `searchAgentInstances`) for the SETTLED "agent history" panel
|
|
82
|
+
* (issue #745/#747). Optional: when omitted the agent-history panel is not rendered. Must be
|
|
83
|
+
* provided together with {@link fetchAgentHistory}. This is the CONSUMER read path — settled history
|
|
84
|
+
* derives from engine truth keyed by agent-instance / process keys, NOT the slash-bearing relay
|
|
85
|
+
* stream id (so the #744 gateway-proxy bug class is moot); the relay past-sessions panel above stays
|
|
86
|
+
* the LIVE overlay only.
|
|
87
|
+
*/
|
|
88
|
+
readonly fetchAgentInstances?: (processInstanceKey?: string) => Promise<AgentInstanceListReport>;
|
|
89
|
+
/**
|
|
90
|
+
* Fetches one AgentInstance's durable conversation history (turns + per-turn metrics) from engine
|
|
91
|
+
* `searchAgentInstanceHistory` (`GET /agentic/agent-instances/{agentInstanceKey}/history`), for the
|
|
92
|
+
* historical transcript view. Required for the agent-history panel's drill-in to work; must be
|
|
93
|
+
* provided together with {@link fetchAgentInstances}.
|
|
94
|
+
*/
|
|
95
|
+
readonly fetchAgentHistory?: (agentInstanceKey: string) => Promise<AgentHistoryReport>;
|
|
72
96
|
/** Opens a socket to the app relay channel (one per drill-in connection). */
|
|
73
97
|
readonly connectRelay: SocketFactory;
|
|
74
98
|
/** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
|
|
@@ -117,6 +141,8 @@ export interface SupplyCockpitHandle {
|
|
|
117
141
|
drill(stream: string): void;
|
|
118
142
|
/** Replay a captured past session's stored transcript statically into the terminal (no live worker). */
|
|
119
143
|
replay(stream: string): Promise<void>;
|
|
144
|
+
/** View one engine-native agent instance's settled conversation history in the agent-history panel. */
|
|
145
|
+
viewAgentHistory(agentInstanceKey: string): Promise<void>;
|
|
120
146
|
/** Open a worker's dedicated detail page. */
|
|
121
147
|
openWorker(instance: string): void;
|
|
122
148
|
/** Return to the main worker list. */
|
|
@@ -125,6 +151,8 @@ export interface SupplyCockpitHandle {
|
|
|
125
151
|
readonly currentRoute: CockpitRoute;
|
|
126
152
|
/** The stream currently drilled into or replayed, if any. */
|
|
127
153
|
readonly currentStream: string | undefined;
|
|
154
|
+
/** The agent instance whose settled history is currently shown in the agent-history panel, if any. */
|
|
155
|
+
readonly currentAgentInstanceKey: string | undefined;
|
|
128
156
|
/** Whether the terminal is showing a LIVE stream or a REPLAYED transcript (undefined when idle). */
|
|
129
157
|
readonly currentMode: TerminalMode | undefined;
|
|
130
158
|
/** Stop everything and release the terminal connection. */
|
|
@@ -147,6 +175,11 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
147
175
|
readonly #env: SupplyCockpitEnv;
|
|
148
176
|
readonly #listRegion: ElementLike;
|
|
149
177
|
readonly #pastRegion: ElementLike | undefined;
|
|
178
|
+
// The engine-native SETTLED agent-history panel (issue #745): a list region (the AgentInstance runs)
|
|
179
|
+
// and a detail region (a selected instance's ordered conversation turns + metrics). Present only when
|
|
180
|
+
// the engine agent-history read endpoints are wired. Distinct from #pastRegion (the relay live overlay).
|
|
181
|
+
readonly #agentRegion: ElementLike | undefined;
|
|
182
|
+
readonly #agentDetailRegion: ElementLike | undefined;
|
|
150
183
|
// A dedicated volatile region the STRUCTURED derived view (messages, rich tool/diff cards, permission
|
|
151
184
|
// prompts) is mounted into on a replay — beside, and additive to, the byte-level terminal replay
|
|
152
185
|
// (which is left untouched). Present only when the transcript read endpoints are wired.
|
|
@@ -182,6 +215,12 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
182
215
|
// hung transcripts endpoint can't accumulate pending calls.
|
|
183
216
|
#pastRefreshing = false;
|
|
184
217
|
#pastRefreshPending = false;
|
|
218
|
+
// Single-flight latch for the agent-history list refresh (mirrors #pastRefreshing), so the poll can
|
|
219
|
+
// never stack engine agent-instance fetches against a slow/unresponsive read endpoint.
|
|
220
|
+
#agentRefreshing = false;
|
|
221
|
+
#agentRefreshPending = false;
|
|
222
|
+
// The agent instance whose settled history is currently rendered in the detail region, if any.
|
|
223
|
+
#shownAgentInstanceKey: string | undefined;
|
|
185
224
|
#route: CockpitRoute = { kind: "main" };
|
|
186
225
|
#view: SupplyView | undefined;
|
|
187
226
|
// Bumped by every start()/stop() so an in-flight #tick() from a previous start cycle can't
|
|
@@ -217,6 +256,12 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
217
256
|
if ((env.fetchTranscripts === undefined) !== (env.fetchTranscript === undefined)) {
|
|
218
257
|
throw new Error("SupplyCockpitEnv.fetchTranscripts and fetchTranscript must be provided together (or neither)");
|
|
219
258
|
}
|
|
259
|
+
// The engine agent-history LIST source and the per-instance HISTORY source are likewise a matched
|
|
260
|
+
// pair: the panel renders whenever the list source is present, but its rows route through
|
|
261
|
+
// viewAgentHistory(), which no-ops without the history source. Require both together (or neither).
|
|
262
|
+
if ((env.fetchAgentInstances === undefined) !== (env.fetchAgentHistory === undefined)) {
|
|
263
|
+
throw new Error("SupplyCockpitEnv.fetchAgentInstances and fetchAgentHistory must be provided together (or neither)");
|
|
264
|
+
}
|
|
220
265
|
this.#setTimer =
|
|
221
266
|
env.setTimer ??
|
|
222
267
|
((run, ms) => {
|
|
@@ -255,6 +300,14 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
255
300
|
this.#pastRegion = env.doc.createElement("div");
|
|
256
301
|
this.#pastRegion.className = "cockpit-past-region";
|
|
257
302
|
}
|
|
303
|
+
// The engine-native agent-history panel: a list region + a detail region, present only when the
|
|
304
|
+
// engine agent-history read endpoints are wired.
|
|
305
|
+
if (env.fetchAgentInstances !== undefined) {
|
|
306
|
+
this.#agentRegion = env.doc.createElement("div");
|
|
307
|
+
this.#agentRegion.className = "cockpit-agent-region";
|
|
308
|
+
this.#agentDetailRegion = env.doc.createElement("div");
|
|
309
|
+
this.#agentDetailRegion.className = "cockpit-agent-detail-region";
|
|
310
|
+
}
|
|
258
311
|
this.#terminalPanel = env.doc.createElement("section");
|
|
259
312
|
this.#terminalPanel.className = "cockpit-terminal";
|
|
260
313
|
this.#terminalPanel.setAttribute("data-terminal-mode", "idle");
|
|
@@ -282,9 +335,14 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
282
335
|
this.#terminalNote.className = "cockpit-terminal-note";
|
|
283
336
|
this.#terminalNote.setAttribute("data-terminal-note", "none");
|
|
284
337
|
this.#terminalPanel.appendChild(this.#terminalNote);
|
|
338
|
+
// Order MUST match the browser twin (pages/cockpit/mount.js) and its tests: the terminal sits
|
|
339
|
+
// directly beneath the supply list, since cockpit.css keys layout off DOM order (no grid areas).
|
|
340
|
+
// supply list → terminal → past sessions → agent list → agent detail.
|
|
285
341
|
shell.appendChild(this.#listRegion);
|
|
286
|
-
if (this.#pastRegion !== undefined) shell.appendChild(this.#pastRegion);
|
|
287
342
|
shell.appendChild(this.#terminalPanel);
|
|
343
|
+
if (this.#pastRegion !== undefined) shell.appendChild(this.#pastRegion);
|
|
344
|
+
if (this.#agentRegion !== undefined) shell.appendChild(this.#agentRegion);
|
|
345
|
+
if (this.#agentDetailRegion !== undefined) shell.appendChild(this.#agentDetailRegion);
|
|
288
346
|
env.host.appendChild(shell);
|
|
289
347
|
}
|
|
290
348
|
|
|
@@ -292,6 +350,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
292
350
|
return this.#shownStream;
|
|
293
351
|
}
|
|
294
352
|
|
|
353
|
+
get currentAgentInstanceKey(): string | undefined {
|
|
354
|
+
return this.#shownAgentInstanceKey;
|
|
355
|
+
}
|
|
356
|
+
|
|
295
357
|
get currentMode(): TerminalMode | undefined {
|
|
296
358
|
return this.#mode;
|
|
297
359
|
}
|
|
@@ -350,6 +412,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
350
412
|
// transcripts endpoint that hangs (not just rejects) would otherwise stall #refresh() forever and
|
|
351
413
|
// wedge the live worker list. #refreshPast is single-flight, so a slow fetch can't pile up either.
|
|
352
414
|
void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
|
|
415
|
+
// Same fire-and-forget discipline for the engine agent-history list: a slow/hung read endpoint must
|
|
416
|
+
// never gate the supply poll's next tick. #refreshAgentHistory is single-flight + bounded. The list
|
|
417
|
+
// is engine-global (not route-filtered), so it takes no route instance — call it with no argument.
|
|
418
|
+
void this.#refreshAgentHistory();
|
|
353
419
|
}
|
|
354
420
|
|
|
355
421
|
#renderRoute(): void {
|
|
@@ -413,6 +479,73 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
413
479
|
}
|
|
414
480
|
}
|
|
415
481
|
|
|
482
|
+
/** Fetch + render the engine-native SETTLED agent-history list, when the read endpoints are wired.
|
|
483
|
+
* Single-flight + bounded (mirrors {@link #refreshPast}): an engine read fault/hang never blocks the
|
|
484
|
+
* live worker list. The list is engine-global (settled AgentInstances), so it is not route-filtered. */
|
|
485
|
+
async #refreshAgentHistory(): Promise<void> {
|
|
486
|
+
const fetchAgentInstances = this.#env.fetchAgentInstances;
|
|
487
|
+
if (fetchAgentInstances === undefined || this.#agentRegion === undefined) return;
|
|
488
|
+
if (this.#agentRefreshing) {
|
|
489
|
+
this.#agentRefreshPending = true;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
this.#agentRefreshing = true;
|
|
493
|
+
try {
|
|
494
|
+
let report: AgentInstanceListReport;
|
|
495
|
+
try {
|
|
496
|
+
report = await this.#bounded(() => fetchAgentInstances(), "agent-instances");
|
|
497
|
+
} catch (err) {
|
|
498
|
+
if (this.#disposed) return;
|
|
499
|
+
this.#env.onError?.(err);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (this.#disposed || this.#agentRegion === undefined) return;
|
|
503
|
+
try {
|
|
504
|
+
renderAgentSessions(this.#agentRegion, this.#env.doc, agentSessionsView(report), {
|
|
505
|
+
onSelect: (agentInstanceKey) => void this.viewAgentHistory(agentInstanceKey),
|
|
506
|
+
...(this.#shownAgentInstanceKey !== undefined ? { activeInstanceKey: this.#shownAgentInstanceKey } : {}),
|
|
507
|
+
});
|
|
508
|
+
} catch (err) {
|
|
509
|
+
this.#env.onError?.(err);
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
this.#agentRefreshing = false;
|
|
513
|
+
if (this.#agentRefreshPending && !this.#disposed) {
|
|
514
|
+
this.#agentRefreshPending = false;
|
|
515
|
+
void this.#refreshAgentHistory();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Fetch + render one engine-native agent instance's SETTLED conversation history (turns + per-turn /
|
|
522
|
+
* instance metrics) into the detail region, keyed by `agentInstanceKey` — the CONSUMER read path
|
|
523
|
+
* (issue #745/#747). Bounded so a hung engine read can't wedge the panel. Read-as-absence: an unknown
|
|
524
|
+
* key renders an explicit empty history, never an error.
|
|
525
|
+
*/
|
|
526
|
+
async viewAgentHistory(agentInstanceKey: string): Promise<void> {
|
|
527
|
+
if (this.#disposed) return;
|
|
528
|
+
const fetchAgentHistory = this.#env.fetchAgentHistory;
|
|
529
|
+
if (fetchAgentHistory === undefined || this.#agentDetailRegion === undefined) return;
|
|
530
|
+
let report: AgentHistoryReport;
|
|
531
|
+
try {
|
|
532
|
+
report = await this.#bounded(() => fetchAgentHistory(agentInstanceKey), "agent-history");
|
|
533
|
+
} catch (err) {
|
|
534
|
+
if (this.#disposed) return;
|
|
535
|
+
this.#env.onError?.(err);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (this.#disposed || this.#agentDetailRegion === undefined) return;
|
|
539
|
+
try {
|
|
540
|
+
this.#shownAgentInstanceKey = agentInstanceKey;
|
|
541
|
+
renderAgentHistory(this.#agentDetailRegion, this.#env.doc, agentHistoryView(report));
|
|
542
|
+
// Re-render the list so the just-selected run shows as active (best-effort).
|
|
543
|
+
void this.#refreshAgentHistory();
|
|
544
|
+
} catch (err) {
|
|
545
|
+
this.#env.onError?.(err);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
416
549
|
/**
|
|
417
550
|
* Race an injected fetch against a timeout so the returned promise ALWAYS settles, even if the fetch
|
|
418
551
|
* HANGS (never settles, not merely rejects). Both single-flight callers below — the past-sessions list
|
|
@@ -53,7 +53,7 @@ export interface SupplyLeafReport {
|
|
|
53
53
|
export interface SupplyCorrelationReport {
|
|
54
54
|
/** The Camunda-8 job key. */
|
|
55
55
|
readonly jobKey: string;
|
|
56
|
-
/** The relay stream the job's terminal is on (`
|
|
56
|
+
/** The relay stream the job's terminal is on (`composeStreamId(instance, jobKey)`, issue #738). */
|
|
57
57
|
readonly stream: string;
|
|
58
58
|
/** The owning process instance key, if known. */
|
|
59
59
|
readonly processInstanceKey?: string;
|
|
@@ -113,8 +113,8 @@ export interface SupplyWorkerView {
|
|
|
113
113
|
readonly jobs: number;
|
|
114
114
|
/**
|
|
115
115
|
* Whether this worker has a LIVE terminal to drill into. True only while it holds a current job:
|
|
116
|
-
* a worker relays its terminal on the
|
|
117
|
-
* repoints {@link stream} at it. An IDLE worker (no jobs) has its `stream` default back to the bare
|
|
116
|
+
* a worker relays its terminal on the instance-scoped `composeStreamId(instance, jobKey)` stream
|
|
117
|
+
* (issue #738), and the supply endpoint repoints {@link stream} at it. An IDLE worker (no jobs) has its `stream` default back to the bare
|
|
118
118
|
* instance id — a stream NO producer ever writes to — so drilling it opens a permanently blank
|
|
119
119
|
* "live" terminal. The renderer suppresses the drill affordance when this is false.
|
|
120
120
|
*/
|
|
@@ -41,7 +41,7 @@ export interface TranscriptListReport {
|
|
|
41
41
|
|
|
42
42
|
/** One past-session row in the renderable history view. */
|
|
43
43
|
export interface TranscriptView {
|
|
44
|
-
/** The relay stream id to replay (`
|
|
44
|
+
/** The relay stream id to replay (`composeStreamId(instance, jobKey)` for a job stream, issue #738). */
|
|
45
45
|
readonly stream: string;
|
|
46
46
|
/** A single stable human label for the session's process instance / plan (falls back to the stream). */
|
|
47
47
|
readonly label: string;
|
|
@@ -10,9 +10,13 @@ import { test } from "node:test";
|
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import type { SqliteDb } from "@nanobpm/agentic/transcript";
|
|
12
12
|
import { assert, assertEquals } from "#test-assert";
|
|
13
|
-
import {
|
|
13
|
+
import { composeStreamId } from "@nanobpm/agentic/emit";
|
|
14
14
|
import { AGENTIC_CORRELATION_SCHEMA_SQL, AgenticCorrelationStore } from "./correlation-store.ts";
|
|
15
15
|
|
|
16
|
+
/** The instance-scoped transcript stream id a job's terminal is stored under (issue #738). The
|
|
17
|
+
* worker instance is fixed here — `byStream` only recovers the jobKey (the stream part) from it. */
|
|
18
|
+
const st = (jobKey: string): string => composeStreamId("worker-A", jobKey);
|
|
19
|
+
|
|
16
20
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
21
|
|
|
18
22
|
function memoryDb(): SqliteDb {
|
|
@@ -85,7 +89,7 @@ test("record + get round-trips full attribution, and byStream decodes the jobKey
|
|
|
85
89
|
const store = new AgenticCorrelationStore(memoryDb());
|
|
86
90
|
store.record({
|
|
87
91
|
jobKey: "job-1",
|
|
88
|
-
stream:
|
|
92
|
+
stream: st("job-1"),
|
|
89
93
|
instance: "worker-A",
|
|
90
94
|
identity: "leaf:token",
|
|
91
95
|
host: "merlin.local",
|
|
@@ -105,7 +109,7 @@ test("record + get round-trips full attribution, and byStream decodes the jobKey
|
|
|
105
109
|
assertEquals(got?.planKey, "owner/repo#142");
|
|
106
110
|
assertEquals(got?.completedAt, "2026-08-23T00:05:00.000Z");
|
|
107
111
|
// The same row is reachable from the stream id.
|
|
108
|
-
assertEquals(store.byStream(
|
|
112
|
+
assertEquals(store.byStream(st("job-1"))?.instance, "worker-A");
|
|
109
113
|
});
|
|
110
114
|
|
|
111
115
|
test("byElementInstance keys per-occupancy, distinguishing a looping/retried activity's iterations", () => {
|
|
@@ -115,7 +119,7 @@ test("byElementInstance keys per-occupancy, distinguishing a looping/retried act
|
|
|
115
119
|
// element-instance key each resolves to its own attribution (the whole point of #544).
|
|
116
120
|
store.record({
|
|
117
121
|
jobKey: "job-iter-1",
|
|
118
|
-
stream:
|
|
122
|
+
stream: st("job-iter-1"),
|
|
119
123
|
instance: "worker-A",
|
|
120
124
|
processInstanceKey: "pi-9",
|
|
121
125
|
elementId: "agent",
|
|
@@ -124,7 +128,7 @@ test("byElementInstance keys per-occupancy, distinguishing a looping/retried act
|
|
|
124
128
|
});
|
|
125
129
|
store.record({
|
|
126
130
|
jobKey: "job-iter-2",
|
|
127
|
-
stream:
|
|
131
|
+
stream: st("job-iter-2"),
|
|
128
132
|
instance: "worker-A",
|
|
129
133
|
processInstanceKey: "pi-9",
|
|
130
134
|
elementId: "agent",
|
|
@@ -147,7 +151,7 @@ test("optional context columns are omitted (not null) when unknown", () => {
|
|
|
147
151
|
const store = new AgenticCorrelationStore(memoryDb());
|
|
148
152
|
store.record({
|
|
149
153
|
jobKey: "job-2",
|
|
150
|
-
stream:
|
|
154
|
+
stream: st("job-2"),
|
|
151
155
|
instance: "worker-B",
|
|
152
156
|
completedAt: "2026-08-23T01:00:00.000Z",
|
|
153
157
|
});
|
|
@@ -159,7 +163,7 @@ test("optional context columns are omitted (not null) when unknown", () => {
|
|
|
159
163
|
|
|
160
164
|
test("record is an upsert: re-recording a jobKey is last-write-wins", () => {
|
|
161
165
|
const store = new AgenticCorrelationStore(memoryDb());
|
|
162
|
-
const base = { jobKey: "job-3", stream:
|
|
166
|
+
const base = { jobKey: "job-3", stream: st("job-3"), completedAt: "2026-08-23T02:00:00.000Z" };
|
|
163
167
|
store.record({ ...base, instance: "worker-C" });
|
|
164
168
|
store.record({ ...base, instance: "worker-C", host: "second.local", completedAt: "2026-08-23T02:10:00.000Z" });
|
|
165
169
|
const got = store.get("job-3");
|
|
@@ -169,7 +173,7 @@ test("record is an upsert: re-recording a jobKey is last-write-wins", () => {
|
|
|
169
173
|
|
|
170
174
|
test("record preserves an existing element_instance_key when a later re-record omits it (monotonic)", () => {
|
|
171
175
|
const store = new AgenticCorrelationStore(memoryDb());
|
|
172
|
-
const base = { jobKey: "job-4", stream:
|
|
176
|
+
const base = { jobKey: "job-4", stream: st("job-4"), completedAt: "2026-08-23T02:00:00.000Z" };
|
|
173
177
|
// The durable backfill path (or a first record that carried the resolved key).
|
|
174
178
|
store.record({ ...base, instance: "worker-D", elementInstanceKey: "ei-777" });
|
|
175
179
|
// A later best-effort re-record that does NOT know the key must not wipe it back to NULL.
|
|
@@ -178,9 +182,9 @@ test("record preserves an existing element_instance_key when a later re-record o
|
|
|
178
182
|
assertEquals(got?.host, "later.local");
|
|
179
183
|
assertEquals(got?.elementInstanceKey, "ei-777");
|
|
180
184
|
// setElementInstanceKey backfill then a bare re-record likewise survives.
|
|
181
|
-
store.record({ jobKey: "job-5", stream:
|
|
185
|
+
store.record({ jobKey: "job-5", stream: st("job-5"), completedAt: "2026-08-23T03:00:00.000Z", instance: "worker-E" });
|
|
182
186
|
store.setElementInstanceKey("job-5", "ei-888");
|
|
183
|
-
store.record({ jobKey: "job-5", stream:
|
|
187
|
+
store.record({ jobKey: "job-5", stream: st("job-5"), completedAt: "2026-08-23T03:05:00.000Z", instance: "worker-E" });
|
|
184
188
|
assertEquals(store.get("job-5")?.elementInstanceKey, "ei-888");
|
|
185
189
|
});
|
|
186
190
|
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// `element_instance_key`). A drift-guard test (`correlation-store.test.ts`) applies those migrations to
|
|
20
20
|
// one DB and the canonical DDL to another and asserts the two schemas are identical, so they can never
|
|
21
21
|
// diverge (the migrations, once merged, are immutable — the canonical DDL is what evolves).
|
|
22
|
+
|
|
23
|
+
import { parseStreamId } from "@nanobpm/agentic/emit";
|
|
22
24
|
import type { SqliteDb } from "@nanobpm/agentic/transcript";
|
|
23
|
-
import { jobKeyOfStream } from "./correlation.ts";
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* The canonical DDL for the durable correlation table. The `078_*` + `086_*` migrations reproduce this
|
|
@@ -183,9 +184,9 @@ export class AgenticCorrelationStore {
|
|
|
183
184
|
return rows.length > 0 ? fromRow(rows[0]) : undefined;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
/** The durable attribution for
|
|
187
|
+
/** The durable attribution for an instance-scoped job stream id, or undefined for a non-job stream. */
|
|
187
188
|
byStream(stream: string): DurableCorrelation | undefined {
|
|
188
|
-
const jobKey =
|
|
189
|
+
const jobKey = parseStreamId(stream)?.stream;
|
|
189
190
|
return jobKey === undefined ? undefined : this.get(jobKey);
|
|
190
191
|
}
|
|
191
192
|
|
|
@@ -1,32 +1,36 @@
|
|
|
1
1
|
// Unit tests for the jobKey ⇄ process/plan correlation registry (ADR 0056, H6 / #149).
|
|
2
2
|
//
|
|
3
3
|
// The registry is the single canonical join the cockpit uses to line a worker's terminal up with the
|
|
4
|
-
// process instance / plan it belongs to. These tests pin: the `
|
|
5
|
-
// two derived-from-one-write projections
|
|
6
|
-
// across link / re-link (move) / releaseJob /
|
|
7
|
-
// drill `primaryStreamFor`; and the sorted snapshot.
|
|
4
|
+
// process instance / plan it belongs to. These tests pin: the instance-scoped `composeStreamId`
|
|
5
|
+
// transcript-stream convention (issue #738); the two derived-from-one-write projections
|
|
6
|
+
// (instance→jobKeys and jobKey→context) staying consistent across link / re-link (move) / releaseJob /
|
|
7
|
+
// releaseInstance; the presence `jobKeysFor` seam; the drill `primaryStreamFor`; and the sorted snapshot.
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { test } from "node:test";
|
|
10
10
|
|
|
11
|
+
import { composeStreamId, parseStreamId } from "@nanobpm/agentic/emit";
|
|
11
12
|
import {
|
|
12
13
|
CorrelationRegistry,
|
|
13
14
|
currentCorrelation,
|
|
14
15
|
JOB_STREAM_PREFIX,
|
|
15
|
-
jobKeyOfStream,
|
|
16
16
|
jobStream,
|
|
17
17
|
setCurrentCorrelation,
|
|
18
18
|
} from "./correlation.ts";
|
|
19
19
|
|
|
20
|
-
test("jobStream
|
|
20
|
+
test("jobStream builds the bare, slash-free job: id retained for the aux (Explorer/permission) surfaces", () => {
|
|
21
21
|
assert.equal(jobStream("6494"), `${JOB_STREAM_PREFIX}6494`);
|
|
22
|
-
assert.equal(
|
|
23
|
-
assert.equal(jobKeyOfStream("wk-a"), undefined);
|
|
24
|
-
// A bare `job:` prefix carries no jobKey, so it maps to undefined (not "") — an empty jobKey is
|
|
25
|
-
// invalid (link() ignores it), so callers never mistake it for a valid key.
|
|
26
|
-
assert.equal(jobKeyOfStream("job:"), undefined);
|
|
22
|
+
assert.equal(JOB_STREAM_PREFIX, "job:");
|
|
27
23
|
});
|
|
28
24
|
|
|
29
|
-
test("
|
|
25
|
+
test("the transcript stream is the instance-scoped composeStreamId id, round-tripped by parseStreamId (#738)", () => {
|
|
26
|
+
const stream = composeStreamId("wk-a", "6494");
|
|
27
|
+
const ref = parseStreamId(stream);
|
|
28
|
+
assert.ok(ref);
|
|
29
|
+
assert.equal(ref.instance, "wk-a");
|
|
30
|
+
assert.equal(ref.stream, "6494");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("link records context and both projections; resolve carries the instance-scoped stream", () => {
|
|
30
34
|
const reg = new CorrelationRegistry();
|
|
31
35
|
reg.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" });
|
|
32
36
|
|
|
@@ -34,7 +38,7 @@ test("link records context and both projections; resolve carries the job: stream
|
|
|
34
38
|
const c = reg.resolve("6494");
|
|
35
39
|
assert.ok(c);
|
|
36
40
|
assert.equal(c.jobKey, "6494");
|
|
37
|
-
assert.equal(c.stream, "
|
|
41
|
+
assert.equal(c.stream, composeStreamId("wk-a", "6494"));
|
|
38
42
|
assert.equal(c.processInstanceKey, "4612");
|
|
39
43
|
assert.equal(c.bpmnProcessId, "plan-fanout");
|
|
40
44
|
assert.equal(c.elementId, "implement-task");
|
|
@@ -54,7 +58,7 @@ test("attachElementInstance enriches a linked job's context, preserving every ot
|
|
|
54
58
|
assert.equal(c.elementId, "agent");
|
|
55
59
|
assert.equal(c.planKey, "o/r#142");
|
|
56
60
|
assert.equal(c.jobKey, "6494");
|
|
57
|
-
assert.equal(c.stream, "
|
|
61
|
+
assert.equal(c.stream, composeStreamId("wk-a", "6494"));
|
|
58
62
|
});
|
|
59
63
|
|
|
60
64
|
test("attachElementInstance is a no-op for a released (or never-linked) job or an empty key (#544)", () => {
|
|
@@ -145,7 +149,7 @@ test("primaryStreamFor picks the lowest-sorted job's stream; undefined when none
|
|
|
145
149
|
assert.equal(reg.primaryStreamFor("wk-a"), undefined);
|
|
146
150
|
reg.link("wk-a", "50");
|
|
147
151
|
reg.link("wk-a", "10");
|
|
148
|
-
assert.equal(reg.primaryStreamFor("wk-a"), "
|
|
152
|
+
assert.equal(reg.primaryStreamFor("wk-a"), composeStreamId("wk-a", "10"));
|
|
149
153
|
});
|
|
150
154
|
|
|
151
155
|
test("snapshot returns every job sorted by jobKey", () => {
|
|
@@ -156,7 +160,11 @@ test("snapshot returns every job sorted by jobKey", () => {
|
|
|
156
160
|
const snap = reg.snapshot();
|
|
157
161
|
assert.equal(snap.count, 3);
|
|
158
162
|
assert.deepEqual(snap.correlations.map((c) => c.jobKey), ["10", "20", "30"]);
|
|
159
|
-
assert.deepEqual(snap.correlations.map((c) => c.stream), [
|
|
163
|
+
assert.deepEqual(snap.correlations.map((c) => c.stream), [
|
|
164
|
+
composeStreamId("wk-b", "10"),
|
|
165
|
+
composeStreamId("wk-c", "20"),
|
|
166
|
+
composeStreamId("wk-a", "30"),
|
|
167
|
+
]);
|
|
160
168
|
});
|
|
161
169
|
|
|
162
170
|
test("link with no context leaves optional fields unset (no undefined holes)", () => {
|