@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,42 @@
1
+ // GET /app/api/agentic/agent-instances → operationId `listAgentInstances` (issue #745/#747, umbrella #746).
2
+ //
3
+ // The CONSUMER half of the engine-native agent-transcript work: list the durable AgentInstances the
4
+ // worker harness minted (against the `<zeebe:agentDefinition agentType="external"/>` marker, #748), read
5
+ // back from the engine read model through the SINGLE engine-read seam — `@nanobpm/urban`'s `EngineClient`
6
+ // (`searchAgentInstances`, added in urban 0.93 / nanobpm/nano-ide#563). Feeds the cockpit "historical
7
+ // sessions" view (settled history = engine; the token-granular relay stays the LIVE overlay only).
8
+ //
9
+ // Keyed/filtered by process / element / status — NEVER the slash-bearing `job:<jobKey>` relay stream id,
10
+ // so the #744 gateway-proxy bug class is moot for settled history. Advisory read-only (ADR 0056): it
11
+ // observes the engine read model, never activates/completes a job or gates a sequence flow.
12
+ //
13
+ // Read-as-absence: the testkit WASM double records no AgentInstance channel (returns an empty list), and
14
+ // a live engine with no matching instance does the same — an empty list is a 200, never an error. The
15
+ // optional shared-secret guard mirrors the other agentic reads (x-hook-secret when NANO_PR_WEBHOOK_SECRET
16
+ // is set; unset -> open).
17
+
18
+ import { type AgentInstanceQuery, listAgentInstances } from "../app/agentic/agent-history.ts";
19
+ import { envVar } from "../app/version.ts";
20
+ import { defineOperation } from "../nano-generated/operations.ts";
21
+
22
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
23
+
24
+ export default defineOperation("listAgentInstances", async ({ query, req }, app) => {
25
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
26
+ app.log.warn("listAgentInstances rejected: missing/invalid shared secret");
27
+ return { status: 401, body: { error: "unauthorized" } };
28
+ }
29
+ if (!app.engine) {
30
+ app.log.warn("listAgentInstances: no engine client configured — no agent-history read path");
31
+ return { status: 503, body: { error: "no engine read path available" } };
32
+ }
33
+
34
+ const filter: AgentInstanceQuery = {
35
+ ...(query.processInstanceKey !== undefined ? { processInstanceKey: query.processInstanceKey } : {}),
36
+ ...(query.rootProcessInstanceKey !== undefined ? { rootProcessInstanceKey: query.rootProcessInstanceKey } : {}),
37
+ ...(query.elementId !== undefined ? { elementId: query.elementId } : {}),
38
+ ...(query.status !== undefined ? { status: query.status } : {}),
39
+ };
40
+ const body = await listAgentInstances(app.engine, filter);
41
+ return { status: 200, body };
42
+ });
@@ -11,6 +11,7 @@ import type { Frame } from "@nanobpm/agentic/protocol";
11
11
  import type { SqliteDb } from "@nanobpm/agentic/transcript";
12
12
  import type { AppApi, DataLayer } from "@nanobpm/urban";
13
13
  import { assert, assertEquals } from "#test-assert";
14
+ import { composeStreamId } from "@nanobpm/agentic/emit";
14
15
  import { currentCorrelation } from "../app/agentic/correlation.ts";
15
16
  import { family as correlationFamily } from "../app/agentic/families/correlation.family.ts";
16
17
  import { createRelayFamily, currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
@@ -78,8 +79,8 @@ test("projects the TranscriptStore rows into the list (byteLength, chunkCount, l
78
79
  relayFamily.mount(mountCtx(memSqlite()));
79
80
  const store = currentRelayTranscriptService()?.store;
80
81
  assert(store !== undefined, "the relay family installs a persisted store");
81
- // Seed one completed ephemeral session on a jobKey-scoped stream.
82
- store.flush("job:6494", { since: () => ({ entries: [{ offset: 0, chunk: "hello " }, { offset: 1, chunk: "world" }] }), nextOffset: 2 }, "ephemeral");
82
+ // Seed one completed ephemeral session on an instance-scoped stream (issue #738).
83
+ store.flush(composeStreamId("wk", "6494"), { since: () => ({ entries: [{ offset: 0, chunk: "hello " }, { offset: 1, chunk: "world" }] }), nextOffset: 2 }, "ephemeral");
83
84
  try {
84
85
  const res = (await handler(input(), app)) as {
85
86
  status: number;
@@ -89,8 +90,8 @@ test("projects the TranscriptStore rows into the list (byteLength, chunkCount, l
89
90
  assertEquals(res.body.count, 1);
90
91
  assert(typeof res.body.retentionMs === "number", "the list surfaces the retention window");
91
92
  const t = res.body.transcripts[0];
92
- assertEquals(t.stream, "job:6494");
93
- assertEquals(t.jobKey, "6494", "the jobKey is decoded from the job: stream id");
93
+ assertEquals(t.stream, composeStreamId("wk", "6494"));
94
+ assertEquals(t.jobKey, "6494", "the jobKey is decoded from the instance-scoped stream id");
94
95
  assertEquals(t.lifecycle, "ephemeral");
95
96
  assertEquals(t.status, "completed");
96
97
  assertEquals(t.chunkCount, 2);
@@ -105,7 +106,7 @@ test("enriches with the H6 correlation (process instance / plan) when it is stil
105
106
  relayFamily.mount(mountCtx(memSqlite()));
106
107
  const store = currentRelayTranscriptService()?.store;
107
108
  assert(store !== undefined);
108
- store.flush("job:6494", { since: () => ({ entries: [{ offset: 0, chunk: "x" }] }), nextOffset: 1 }, "ephemeral");
109
+ store.flush(composeStreamId("wk-a", "6494"), { since: () => ({ entries: [{ offset: 0, chunk: "x" }] }), nextOffset: 1 }, "ephemeral");
109
110
  correlationFamily.mount({ hub: undefined as never, registry: undefined as never, transport: undefined as never, data: undefined, log: noopLog() });
110
111
  currentCorrelation()?.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", planKey: "o/r#142" });
111
112
  try {
@@ -124,18 +125,18 @@ test("filters by jobKey and plan", async () => {
124
125
  relayFamily.mount(mountCtx(memSqlite()));
125
126
  const store = currentRelayTranscriptService()?.store;
126
127
  assert(store !== undefined);
127
- store.flush("job:1", { since: () => ({ entries: [{ offset: 0, chunk: "a" }] }), nextOffset: 1 }, "ephemeral");
128
- store.flush("job:2", { since: () => ({ entries: [{ offset: 0, chunk: "b" }] }), nextOffset: 1 }, "ephemeral");
128
+ store.flush(composeStreamId("wk", "1"), { since: () => ({ entries: [{ offset: 0, chunk: "a" }] }), nextOffset: 1 }, "ephemeral");
129
+ store.flush(composeStreamId("wk", "2"), { since: () => ({ entries: [{ offset: 0, chunk: "b" }] }), nextOffset: 1 }, "ephemeral");
129
130
  correlationFamily.mount({ hub: undefined as never, registry: undefined as never, transport: undefined as never, data: undefined, log: noopLog() });
130
131
  currentCorrelation()?.link("wk", "2", { planKey: "o/r#9" });
131
132
  try {
132
133
  const byJob = (await handler(input({ jobKey: "1" }), app)) as { body: { count: number; transcripts: Array<Record<string, unknown>> } };
133
134
  assertEquals(byJob.body.count, 1);
134
- assertEquals(byJob.body.transcripts[0]?.stream, "job:1");
135
+ assertEquals(byJob.body.transcripts[0]?.stream, composeStreamId("wk", "1"));
135
136
 
136
137
  const byPlan = (await handler(input({ planKey: "o/r#9" }), app)) as { body: { count: number; transcripts: Array<Record<string, unknown>> } };
137
138
  assertEquals(byPlan.body.count, 1);
138
- assertEquals(byPlan.body.transcripts[0]?.stream, "job:2");
139
+ assertEquals(byPlan.body.transcripts[0]?.stream, composeStreamId("wk", "2"));
139
140
  } finally {
140
141
  correlationFamily.teardown?.();
141
142
  relayFamily.teardown?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.183.1",
3
+ "version": "0.184.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -65,12 +65,12 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@nanobpm/agentic": "^0.13.0",
68
- "@nanobpm/urban": "^0.92.0",
68
+ "@nanobpm/urban": "^0.93.0",
69
69
  "bpmn-auto-layout": "^2.0.0-alpha.2"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@biomejs/biome": "^2.4.11",
73
- "@nanobpm/urban-testkit": "^1.3.0",
73
+ "@nanobpm/urban-testkit": "^1.4.0",
74
74
  "@nanobpm/workflow": "^0.14.0",
75
75
  "@semantic-release/changelog": "^7.0.0",
76
76
  "@semantic-release/git": "^11.0.0",
@@ -43,6 +43,8 @@
43
43
 
44
44
  .cockpit-supply-region,
45
45
  .cockpit-past-region,
46
+ .cockpit-agent-region,
47
+ .cockpit-agent-detail-region,
46
48
  .cockpit-terminal {
47
49
  background: var(--cockpit-panel);
48
50
  border: 1px solid var(--cockpit-edge);
@@ -243,6 +245,108 @@
243
245
  padding: 8px 0;
244
246
  }
245
247
 
248
+ /* ── Agent history (engine-native settled AgentInstance/AgentHistory, #745/#747). ─────────────── */
249
+
250
+ .cockpit-agent-header,
251
+ .cockpit-agent-transcript-header {
252
+ display: flex;
253
+ flex-wrap: wrap;
254
+ align-items: baseline;
255
+ justify-content: space-between;
256
+ gap: 8px;
257
+ margin-bottom: 8px;
258
+ }
259
+
260
+ .cockpit-agent-title,
261
+ .cockpit-agent-transcript-title {
262
+ font-size: 13px;
263
+ margin: 0;
264
+ color: var(--cockpit-muted);
265
+ text-transform: uppercase;
266
+ letter-spacing: 0.04em;
267
+ }
268
+
269
+ .cockpit-agent-summary,
270
+ .cockpit-agent-transcript-metrics {
271
+ color: var(--cockpit-muted);
272
+ font-size: 12px;
273
+ font-variant-numeric: tabular-nums;
274
+ }
275
+
276
+ .cockpit-agent-table {
277
+ width: 100%;
278
+ border-collapse: collapse;
279
+ font-variant-numeric: tabular-nums;
280
+ }
281
+
282
+ .cockpit-agent-select {
283
+ background: none;
284
+ border: none;
285
+ color: var(--cockpit-text);
286
+ cursor: pointer;
287
+ font: inherit;
288
+ padding: 0;
289
+ text-align: left;
290
+ text-decoration: underline;
291
+ text-underline-offset: 2px;
292
+ }
293
+
294
+ .cockpit-agent-select:hover { color: #58a6ff; }
295
+
296
+ .cockpit-agent-session[data-active="true"] {
297
+ background: rgba(88, 166, 255, 0.12);
298
+ }
299
+
300
+ .cockpit-agent-status { color: var(--cockpit-muted); }
301
+ .cockpit-agent-metrics { color: var(--cockpit-muted); font-size: 12px; }
302
+ .cockpit-agent-captured { color: var(--cockpit-muted); font-size: 12px; }
303
+
304
+ .cockpit-agent-empty,
305
+ .cockpit-agent-transcript-empty {
306
+ color: var(--cockpit-muted);
307
+ padding: 8px 0;
308
+ }
309
+
310
+ .cockpit-agent-turn {
311
+ border-top: 1px solid var(--cockpit-edge);
312
+ padding: 8px 0;
313
+ }
314
+
315
+ .cockpit-agent-turn-meta {
316
+ display: flex;
317
+ gap: 8px;
318
+ align-items: baseline;
319
+ margin-bottom: 4px;
320
+ }
321
+
322
+ .cockpit-agent-turn-role {
323
+ font-size: 11px;
324
+ text-transform: uppercase;
325
+ letter-spacing: 0.04em;
326
+ color: #58a6ff;
327
+ }
328
+
329
+ .cockpit-agent-turn-iter,
330
+ .cockpit-agent-turn-metrics {
331
+ font-size: 11px;
332
+ color: var(--cockpit-muted);
333
+ font-variant-numeric: tabular-nums;
334
+ }
335
+
336
+ .cockpit-agent-turn-text {
337
+ margin: 0;
338
+ white-space: pre-wrap;
339
+ word-break: break-word;
340
+ font: inherit;
341
+ }
342
+
343
+ .cockpit-agent-turn-tools {
344
+ margin: 4px 0 0;
345
+ padding-left: 18px;
346
+ color: var(--cockpit-muted);
347
+ font-size: 12px;
348
+ }
349
+
246
350
  /* ── Worker detail route (#/cockpit/worker/<instance>): header + current job + filtered history. ── */
247
351
 
248
352
  .cockpit-worker-detail {
@@ -430,6 +430,192 @@ function transcriptSink(host, stream, opts = {}) {
430
430
  };
431
431
  }
432
432
 
433
+ // ── engine agent-history projection + render (mirrors app/agentic/cockpit/agent-history-view.ts + -render.ts) ──
434
+ //
435
+ // The CONSUMER half of the durable-agent-transcript work (issue #745/#747): the SETTLED agent-run list +
436
+ // a selected run's ordered conversation turns + metrics, sourced from the engine read model
437
+ // (`GET /agentic/agent-instances` + `…/{agentInstanceKey}/history`, served from `@nanobpm/urban`'s
438
+ // EngineClient `searchAgentInstances` / `searchAgentInstanceHistory`), keyed by agentInstanceKey — NOT
439
+ // a relay stream id (#744 moot for historical reads). The relay past-sessions panel above stays the
440
+ // LIVE overlay only. Kept a faithful hand-twin of the server view/render modules (mount.js cannot
441
+ // import them); the server modules carry the Node-tested SSOT.
442
+
443
+ function humanCount(n) {
444
+ if (!Number.isFinite(n) || n < 0) return "0";
445
+ if (n < 1000) return String(n);
446
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
447
+ return `${(n / 1_000_000).toFixed(1)}M`;
448
+ }
449
+
450
+ function humanMs(ms) {
451
+ if (ms == null || !Number.isFinite(ms) || ms <= 0) return undefined;
452
+ if (ms < 1000) return `${Math.round(ms)}ms`;
453
+ return `${(ms / 1000).toFixed(1)}s`;
454
+ }
455
+
456
+ function instanceLabel(i) {
457
+ const parts = [];
458
+ if (i.processDefinitionId != null && i.processDefinitionId !== "") parts.push(i.processDefinitionId);
459
+ if (i.elementId != null && i.elementId !== "") parts.push(i.elementId);
460
+ if (i.processInstanceKey != null && i.processInstanceKey !== "") parts.push(`inst ${i.processInstanceKey}`);
461
+ if (parts.length > 0) return parts.join(" \u00b7 ");
462
+ return i.agentInstanceKey;
463
+ }
464
+
465
+ function instanceMetrics(m) {
466
+ if (m == null) return undefined;
467
+ return `${humanCount(m.inputTokens)} in \u00b7 ${humanCount(m.outputTokens)} out \u00b7 ${m.modelCalls} calls \u00b7 ${m.toolCalls} tools`;
468
+ }
469
+
470
+ function agentSessionView(i) {
471
+ const capturedAt = i.completionDate ?? i.lastUpdatedDate ?? i.creationDate;
472
+ return {
473
+ agentInstanceKey: i.agentInstanceKey,
474
+ label: instanceLabel(i),
475
+ status: i.status,
476
+ metrics: instanceMetrics(i.metrics),
477
+ capturedAt: capturedAt != null && capturedAt !== "" ? capturedAt : undefined,
478
+ };
479
+ }
480
+
481
+ function agentSessionsView(report) {
482
+ const sessions = (report.instances ?? [])
483
+ .map(agentSessionView)
484
+ .sort((a, b) => {
485
+ const byTime = String(b.capturedAt ?? "").localeCompare(String(a.capturedAt ?? ""));
486
+ return byTime !== 0 ? byTime : a.agentInstanceKey.localeCompare(b.agentInstanceKey);
487
+ });
488
+ return { sessions, count: sessions.length };
489
+ }
490
+
491
+ function turnText(r) {
492
+ return (r.content ?? [])
493
+ .filter((b) => b.contentType === "TEXT" && b.text != null && b.text !== "")
494
+ .map((b) => b.text)
495
+ .join("\n");
496
+ }
497
+
498
+ function turnMetrics(m) {
499
+ if (m == null) return undefined;
500
+ const dur = humanMs(m.durationMs);
501
+ const base = `${humanCount(m.inputTokens)} in \u00b7 ${humanCount(m.outputTokens)} out`;
502
+ return dur != null ? `${base} \u00b7 ${dur}` : base;
503
+ }
504
+
505
+ function agentHistoryView(report) {
506
+ const turns = (report.records ?? []).map((r) => ({
507
+ historyItemKey: r.historyItemKey,
508
+ loopIteration: r.loopIteration,
509
+ role: r.role,
510
+ text: turnText(r),
511
+ toolCalls: (r.toolCalls ?? []).map((c) => ({ toolCallId: c.toolCallId, toolName: c.toolName, elementId: c.elementId })),
512
+ metrics: turnMetrics(r.metrics),
513
+ }));
514
+ return {
515
+ agentInstanceKey: report.agentInstanceKey,
516
+ instance: report.instance != null ? agentSessionView(report.instance) : undefined,
517
+ turns,
518
+ count: turns.length,
519
+ };
520
+ }
521
+
522
+ function agentSessionRow(doc, s, onSelect, activeInstanceKey) {
523
+ const row = el(doc, "tr", "cockpit-agent-session");
524
+ row.setAttribute("data-agent-instance-key", s.agentInstanceKey);
525
+ row.setAttribute("data-status", s.status);
526
+ if (activeInstanceKey === s.agentInstanceKey) row.setAttribute("data-active", "true");
527
+ const nameCell = el(doc, "td", "cockpit-td cockpit-agent-name");
528
+ const button = el(doc, "button", "cockpit-agent-select", s.label);
529
+ button.setAttribute("type", "button");
530
+ button.setAttribute("data-agent-instance-key", s.agentInstanceKey);
531
+ if (onSelect) button.addEventListener("click", () => onSelect(s.agentInstanceKey));
532
+ nameCell.appendChild(button);
533
+ row.appendChild(nameCell);
534
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-agent-status", s.status));
535
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-agent-metrics", s.metrics ?? ""));
536
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-agent-captured", s.capturedAt ?? ""));
537
+ return row;
538
+ }
539
+
540
+ function renderAgentSessions(host, doc, view, onSelect, activeInstanceKey) {
541
+ host.replaceChildren();
542
+ const root = el(doc, "div", "cockpit-agent-history");
543
+ root.setAttribute("data-session-count", String(view.count));
544
+ const header = el(doc, "header", "cockpit-agent-header");
545
+ header.appendChild(el(doc, "h2", "cockpit-agent-title", "Agent history"));
546
+ const summary = el(doc, "span", "cockpit-agent-summary", String(view.count));
547
+ summary.setAttribute("data-summary", "agent-history");
548
+ header.appendChild(summary);
549
+ root.appendChild(header);
550
+ if (view.count === 0) {
551
+ const empty = el(doc, "div", "cockpit-agent-empty", "No agent runs recorded yet.");
552
+ empty.setAttribute("data-empty", "true");
553
+ root.appendChild(empty);
554
+ host.appendChild(root);
555
+ return;
556
+ }
557
+ const table = el(doc, "table", "cockpit-agent-table");
558
+ const thead = el(doc, "thead", "cockpit-agent-thead");
559
+ const head = el(doc, "tr", "cockpit-agent-head");
560
+ for (const label of ["run", "status", "metrics", "captured"]) head.appendChild(el(doc, "th", "cockpit-th", label));
561
+ thead.appendChild(head);
562
+ table.appendChild(thead);
563
+ const tbody = el(doc, "tbody", "cockpit-agent-tbody");
564
+ for (const s of view.sessions) tbody.appendChild(agentSessionRow(doc, s, onSelect, activeInstanceKey));
565
+ table.appendChild(tbody);
566
+ root.appendChild(table);
567
+ host.appendChild(root);
568
+ }
569
+
570
+ function agentTurnBlock(doc, t) {
571
+ const block = el(doc, "div", "cockpit-agent-turn");
572
+ block.setAttribute("data-history-item-key", t.historyItemKey);
573
+ block.setAttribute("data-role", t.role);
574
+ block.setAttribute("data-loop-iteration", String(t.loopIteration));
575
+ const meta = el(doc, "div", "cockpit-agent-turn-meta");
576
+ meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
577
+ meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
578
+ if (t.metrics != null) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
579
+ block.appendChild(meta);
580
+ if (t.text !== "") block.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
581
+ if (t.toolCalls.length > 0) {
582
+ const tools = el(doc, "ul", "cockpit-agent-turn-tools");
583
+ for (const call of t.toolCalls) {
584
+ const li = el(doc, "li", "cockpit-agent-turn-tool", call.elementId != null && call.elementId !== "" ? `${call.toolName} (${call.elementId})` : call.toolName);
585
+ li.setAttribute("data-tool-call-id", call.toolCallId);
586
+ tools.appendChild(li);
587
+ }
588
+ block.appendChild(tools);
589
+ }
590
+ return block;
591
+ }
592
+
593
+ function renderAgentHistory(host, doc, view) {
594
+ host.replaceChildren();
595
+ const root = el(doc, "div", "cockpit-agent-transcript");
596
+ root.setAttribute("data-agent-instance-key", view.agentInstanceKey);
597
+ root.setAttribute("data-turn-count", String(view.count));
598
+ const header = el(doc, "header", "cockpit-agent-transcript-header");
599
+ header.appendChild(el(doc, "h3", "cockpit-agent-transcript-title", view.instance?.label ?? view.agentInstanceKey));
600
+ if (view.instance?.metrics != null) {
601
+ const m = el(doc, "span", "cockpit-agent-transcript-metrics", view.instance.metrics);
602
+ m.setAttribute("data-summary", "agent-instance-metrics");
603
+ header.appendChild(m);
604
+ }
605
+ root.appendChild(header);
606
+ if (view.count === 0) {
607
+ const empty = el(doc, "div", "cockpit-agent-transcript-empty", "No history for this run.");
608
+ empty.setAttribute("data-empty", "true");
609
+ root.appendChild(empty);
610
+ host.appendChild(root);
611
+ return;
612
+ }
613
+ const turns = el(doc, "div", "cockpit-agent-turns");
614
+ for (const t of view.turns) turns.appendChild(agentTurnBlock(doc, t));
615
+ root.appendChild(turns);
616
+ host.appendChild(root);
617
+ }
618
+
433
619
  // ── boot orchestration (mirrors app/agentic/cockpit/supply-boot.ts) ────────────────────────────
434
620
 
435
621
  /** A WebSocket relay socket factory for the agentic channel at `url`. */
@@ -463,14 +649,19 @@ function relaySocketFactory(url) {
463
649
  * @param {number} [opts.refreshMs] — poll interval (default 2000).
464
650
  * @param {number} [opts.staleAfterMs] — a worker is rendered "stale" once its last heartbeat is at
465
651
  * least this many ms old (default 15000).
466
- * @param {number} [opts.pastFetchTimeoutMs] — upper bound (ms) on a single past-sessions transcripts
467
- * fetch; the fetch is aborted past this so a hung endpoint can't wedge the past panel (default 15000).
652
+ * @param {number} [opts.pastFetchTimeoutMs] — upper bound (ms) on a single bounded engine JSON fetch:
653
+ * both a past-sessions transcripts fetch AND (via `boundedJson`) an engine agent-history fetch are
654
+ * aborted past this so a hung endpoint can't wedge the past or agent-history panel (default 15000).
468
655
  * @param {string} [opts.transcriptsUrl] — the captured-session list endpoint backing the always-on
469
656
  * "past sessions" history + replay (default
470
657
  * `new URL("../app/api/agentic/transcripts", import.meta.url).href`, module-anchored so it
471
658
  * resolves to the app root `<appMount>/app/api/agentic/transcripts`, not the `/cockpit/` shell
472
659
  * base). The per-session replay read uses the proxy-safe `?stream=` query form on this same URL
473
660
  * (#744 — never a `/…/<id>` path segment, which a decoding gateway splits on encoded slashes).
661
+ * @param {string} [opts.agentInstancesUrl] — the engine-native SETTLED agent-history list endpoint
662
+ * (default `new URL("../app/api/agentic/agent-instances", import.meta.url).href`, module-anchored
663
+ * like the others). Selecting a run reads `…/agent-instances/{agentInstanceKey}/history` off this
664
+ * same base (issue #745/#747). Keyed by agentInstanceKey — the engine read seam, not a relay stream.
474
665
  * @returns a handle with `.dispose()`.
475
666
  */
476
667
  export function mountCockpit(host, opts = {}) {
@@ -494,6 +685,7 @@ export function mountCockpit(host, opts = {}) {
494
685
  // injects window.__NANO_APP_VIEW__, so this default is what actually runs there too.
495
686
  const reportUrl = opts.reportUrl ?? new URL("../app/api/agentic/supply", import.meta.url).href;
496
687
  const transcriptsUrl = opts.transcriptsUrl ?? new URL("../app/api/agentic/transcripts", import.meta.url).href;
688
+ const agentInstancesUrl = opts.agentInstancesUrl ?? new URL("../app/api/agentic/agent-instances", import.meta.url).href;
497
689
  const hookSecret = opts.hookSecret;
498
690
  const relayUrl = opts.relayUrl ?? defaultRelayUrl(opts.relayToken, opts.relayCapability);
499
691
  const refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
@@ -531,6 +723,11 @@ export function mountCockpit(host, opts = {}) {
531
723
  const shell = el(doc, "div", "cockpit-shell");
532
724
  const listRegion = el(doc, "div", "cockpit-supply-region");
533
725
  const pastRegion = el(doc, "div", "cockpit-past-region");
726
+ // The engine-native SETTLED agent-history panel (issue #745): a list region + a detail region,
727
+ // sourced from the engine read model and keyed by agentInstanceKey (distinct from the relay
728
+ // past-sessions overlay above).
729
+ const agentRegion = el(doc, "div", "cockpit-agent-region");
730
+ const agentDetailRegion = el(doc, "div", "cockpit-agent-detail-region");
534
731
  const terminalPanel = el(doc, "section", "cockpit-terminal");
535
732
  terminalPanel.setAttribute("data-terminal-mode", "idle");
536
733
  const terminalTitle = el(doc, "h2", "cockpit-panel-title", "Worker terminal");
@@ -547,6 +744,8 @@ export function mountCockpit(host, opts = {}) {
547
744
  shell.appendChild(listRegion);
548
745
  shell.appendChild(terminalPanel);
549
746
  shell.appendChild(pastRegion);
747
+ shell.appendChild(agentRegion);
748
+ shell.appendChild(agentDetailRegion);
550
749
  host.appendChild(shell);
551
750
 
552
751
  let running = false;
@@ -566,6 +765,11 @@ export function mountCockpit(host, opts = {}) {
566
765
  // against a slow/hung transcripts endpoint.
567
766
  let pastRefreshing = false;
568
767
  let pastRefreshPending = false;
768
+ // Single-flight latch for the engine agent-history list refresh (mirrors pastRefreshing), and the
769
+ // agent instance whose settled history is currently shown in the detail region.
770
+ let agentRefreshing = false;
771
+ let agentRefreshPending = false;
772
+ let shownAgentInstanceKey;
569
773
 
570
774
  function setMode(next, stream) {
571
775
  mode = next;
@@ -851,6 +1055,85 @@ export function mountCockpit(host, opts = {}) {
851
1055
  }
852
1056
  // Fire-and-forget: a hung transcripts endpoint must never stall the supply poll's next tick.
853
1057
  void refreshPast(routeInstance());
1058
+ // Same discipline for the engine agent-history list (issue #745): single-flight + bounded.
1059
+ void refreshAgentHistory();
1060
+ }
1061
+
1062
+ // The engine-native SETTLED agent-history endpoints (issue #745/#747), anchored module-relatively
1063
+ // like the other API URLs. The per-instance history rides a PATH segment — an engine agent-instance
1064
+ // key is a plain (slash-free) key, so unlike the slash-bearing relay stream id (#744) it is
1065
+ // proxy-safe as a path segment; encode it defensively all the same.
1066
+ function agentHistoryReadUrl(agentInstanceKey) {
1067
+ const base = new URL(agentInstancesUrl, location.href);
1068
+ base.pathname = `${base.pathname.replace(/\/$/, "")}/${encodeURIComponent(agentInstanceKey)}/history`;
1069
+ return base.href;
1070
+ }
1071
+
1072
+ // Shared bounded-fetch helper for the engine JSON read endpoints (agent-instances list +
1073
+ // per-instance agent-history). Reuses `pastFetchTimeoutMs` as the abort bound — the same discipline
1074
+ // as the past-sessions fetches — so a hung engine read endpoint can't wedge the agent-history panel.
1075
+ async function boundedJson(url) {
1076
+ const controller = new AbortController();
1077
+ const abortTimer = setTimeout(() => controller.abort(), pastFetchTimeoutMs);
1078
+ abortTimer.unref?.();
1079
+ try {
1080
+ const res = await fetch(url, { headers: jsonHeaders(), signal: controller.signal });
1081
+ if (!res.ok) throw new Error(`fetch failed: ${res.status} (${url})`);
1082
+ return await res.json();
1083
+ } finally {
1084
+ clearTimeout(abortTimer);
1085
+ }
1086
+ }
1087
+
1088
+ async function refreshAgentHistory() {
1089
+ // Single-flight (mirrors refreshPast): a slow/hung engine read endpoint never stacks fetches nor
1090
+ // gates the supply poll. The list is engine-global (settled AgentInstances), so it is not route-filtered.
1091
+ if (agentRefreshing) {
1092
+ agentRefreshPending = true;
1093
+ return;
1094
+ }
1095
+ agentRefreshing = true;
1096
+ try {
1097
+ let report;
1098
+ try {
1099
+ report = await boundedJson(agentInstancesUrl);
1100
+ } catch (err) {
1101
+ onError(err);
1102
+ return;
1103
+ }
1104
+ if (disposed) return;
1105
+ try {
1106
+ renderAgentSessions(agentRegion, doc, agentSessionsView(report), viewAgentHistory, shownAgentInstanceKey);
1107
+ } catch (err) {
1108
+ onError(err);
1109
+ }
1110
+ } finally {
1111
+ agentRefreshing = false;
1112
+ if (agentRefreshPending && !disposed) {
1113
+ agentRefreshPending = false;
1114
+ void refreshAgentHistory();
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ async function viewAgentHistory(agentInstanceKey) {
1120
+ if (disposed) return;
1121
+ let report;
1122
+ try {
1123
+ report = await boundedJson(agentHistoryReadUrl(agentInstanceKey));
1124
+ } catch (err) {
1125
+ onError(err);
1126
+ return;
1127
+ }
1128
+ if (disposed) return;
1129
+ try {
1130
+ shownAgentInstanceKey = agentInstanceKey;
1131
+ renderAgentHistory(agentDetailRegion, doc, agentHistoryView(report));
1132
+ // Re-render the list so the just-selected run shows as active (best-effort).
1133
+ void refreshAgentHistory();
1134
+ } catch (err) {
1135
+ onError(err);
1136
+ }
854
1137
  }
855
1138
 
856
1139
  function tick(gen) {
@@ -884,7 +1167,7 @@ export function mountCockpit(host, opts = {}) {
884
1167
  }
885
1168
 
886
1169
  start();
887
- return { start, stop, dispose, refresh, drill: drillInto, replay: replayInto };
1170
+ return { start, stop, dispose, refresh, drill: drillInto, replay: replayInto, viewAgentHistory };
888
1171
  }
889
1172
 
890
1173
  /**
@@ -27,6 +27,7 @@ import type { SqliteDb } from "@nanobpm/agentic/presence";
27
27
  import { decodeFrame, encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
28
28
  import type { AppApi, DataLayer } from "@nanobpm/urban";
29
29
  import { assert, assertEquals } from "#test-assert";
30
+ import { composeStreamId } from "@nanobpm/agentic/emit";
30
31
  import { currentCorrelation } from "../app/agentic/correlation.ts";
31
32
  import { loadAgenticFamilies } from "../app/agentic/loader.ts";
32
33
  import { type AgenticContext, AgenticFamilyRegistry } from "../app/agentic/registry.ts";
@@ -146,7 +147,9 @@ function supplyInput() {
146
147
  test("E2E: the whole visibility plane wires up — presence, correlation, supply report, relay drill, and resume across a hub restart", async () => {
147
148
  const db = memSqlite();
148
149
  const JOB = "6494";
149
- const STREAM = `job:${JOB}`;
150
+ // The producer writes a job's terminal on the instance-scoped stream (issue #738); the drill stream
151
+ // the supply advertises MUST be this exact id, or the cockpit reads a stream that never existed.
152
+ const STREAM = composeStreamId("wk-a", JOB);
150
153
 
151
154
  // Sanity: the fleet the seam discovers really includes presence, relay, and correlation.
152
155
  const fleet = await mountFleet(db);