@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/app/agentic/agent-history.test.ts +165 -0
  3. package/app/agentic/agent-history.ts +211 -0
  4. package/app/agentic/claim-registry.test.ts +3 -2
  5. package/app/agentic/claim-registry.ts +8 -6
  6. package/app/agentic/cockpit/agent-history-render.test.ts +85 -0
  7. package/app/agentic/cockpit/agent-history-render.ts +168 -0
  8. package/app/agentic/cockpit/agent-history-view.test.ts +104 -0
  9. package/app/agentic/cockpit/agent-history-view.ts +186 -0
  10. package/app/agentic/cockpit/index.ts +21 -0
  11. package/app/agentic/cockpit/mount.test.ts +133 -1
  12. package/app/agentic/cockpit/supply-boot-agent-history.test.ts +144 -0
  13. package/app/agentic/cockpit/supply-boot.ts +134 -1
  14. package/app/agentic/cockpit/supply-view.ts +3 -3
  15. package/app/agentic/cockpit/transcript-view.ts +1 -1
  16. package/app/agentic/correlation-store.test.ts +14 -10
  17. package/app/agentic/correlation-store.ts +4 -3
  18. package/app/agentic/correlation.test.ts +24 -16
  19. package/app/agentic/correlation.ts +25 -12
  20. package/app/agentic/families/claim.family.test.ts +2 -1
  21. package/app/agentic/families/relay.family.test.ts +74 -73
  22. package/app/agentic/families/relay.family.ts +24 -21
  23. package/app/agentic/transcript-read.test.ts +108 -17
  24. package/app/agentic/transcript-read.ts +41 -7
  25. package/app/contracts.ts +10 -2
  26. package/app/mcpToolSurface.ts +8 -1
  27. package/db/migrations/101_agentic_history_read_expand.sql +34 -0
  28. package/openapi.yaml +360 -0
  29. package/operations/agentHistoryEndpoints.test.ts +106 -0
  30. package/operations/getAgentInstanceHistory.ts +37 -0
  31. package/operations/getAgenticSupply.test.ts +41 -2
  32. package/operations/getAgenticSupply.ts +7 -5
  33. package/operations/getAgenticTranscript.test.ts +5 -4
  34. package/operations/listAgentInstances.ts +42 -0
  35. package/operations/listAgenticTranscripts.test.ts +10 -9
  36. package/package.json +3 -3
  37. package/pages/cockpit/cockpit.css +104 -0
  38. package/pages/cockpit/mount.js +286 -3
  39. 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 (`job:<jobKey>`). */
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 jobKey-scoped `job:<jobKey>` stream, and the supply endpoint
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 (`job:<jobKey>` for a job stream). */
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 { jobStream } from "./correlation.ts";
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: jobStream("job-1"),
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(jobStream("job-1"))?.instance, "worker-A");
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: jobStream("job-iter-1"),
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: jobStream("job-iter-2"),
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: jobStream("job-2"),
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: jobStream("job-3"), completedAt: "2026-08-23T02:00:00.000Z" };
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: jobStream("job-4"), completedAt: "2026-08-23T02:00:00.000Z" };
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: jobStream("job-5"), completedAt: "2026-08-23T03:00:00.000Z", instance: "worker-E" });
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: jobStream("job-5"), completedAt: "2026-08-23T03:05:00.000Z", instance: "worker-E" });
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 a `job:<jobKey>` stream id, or undefined for a non-job stream. */
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 = jobKeyOfStream(stream);
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 `job:<jobKey>` stream convention; the
5
- // two derived-from-one-write projections (instance→jobKeys and jobKey→context) staying consistent
6
- // across link / re-link (move) / releaseJob / releaseInstance; the presence `jobKeysFor` seam; the
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 / jobKeyOfStream are inverse over the job: convention", () => {
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(jobKeyOfStream(jobStream("6494")), "6494");
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("link records context and both projections; resolve carries the job: stream", () => {
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, "job:6494");
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, "job:6494");
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"), "job:10");
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), ["job:10", "job:20", "job:30"]);
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)", () => {