@nanobpm/nano-workforce 0.183.2 → 0.184.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ // Testkit-boot read-back tests for the engine agent-history endpoints (issue #745/#747):
2
+ // GET /agentic/agent-instances → listAgentInstances
3
+ // GET /agentic/agent-instances/{agentInstanceKey}/history → getAgentInstanceHistory
4
+ //
5
+ // These drive the REAL door through `bootTestApp`'s api driver against the WASM EngineClient double.
6
+ // The testkit engine implements the agent read methods as READ-AS-ABSENCE (it records no AgentInstance
7
+ // channel), so a booted app returns an empty list / empty history rather than an error — exactly the
8
+ // contract the production consumer relies on when an engine has no durable agent history yet.
9
+ // Behavioural parity (non-empty history) is validated against a LIVE engine, out of the testkit's scope.
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { test } from "node:test";
14
+ import type { AppApi } from "@nanobpm/urban";
15
+ import { assertEquals } from "#test-assert";
16
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
17
+ import type { AgentHistoryReader } from "../app/agentic/agent-history.ts";
18
+ import { noopLog } from "../test/log.ts";
19
+ import type { AgentHistory, AgentInstanceList } from "../nano-generated/api-io.d.ts";
20
+
21
+ const APP_ROOT = resolve(import.meta.dirname, "..");
22
+
23
+ async function withApp(fn: (app: TestApp) => Promise<void>): Promise<void> {
24
+ const dir = mkdtempSync(join(tmpdir(), "nwf-agenthist-"));
25
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
26
+ try {
27
+ await fn(app);
28
+ } finally {
29
+ await app.stop?.();
30
+ rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ }
33
+
34
+ test("listAgentInstances: read-as-absence → 200 with an empty instance list", async () => {
35
+ await withApp(async (app) => {
36
+ const res = await app.api.call<AgentInstanceList>("listAgentInstances", {});
37
+ assertEquals(res.status, 200);
38
+ assertEquals(res.body.count, 0);
39
+ assertEquals(res.body.instances.length, 0);
40
+ });
41
+ });
42
+
43
+ test("getAgentInstanceHistory: an unknown key → 200 with an empty history (read-as-absence)", async () => {
44
+ await withApp(async (app) => {
45
+ const res = await app.api.call<AgentHistory>("getAgentInstanceHistory", {
46
+ params: { agentInstanceKey: "no-such-instance" },
47
+ });
48
+ assertEquals(res.status, 200);
49
+ assertEquals(res.body.agentInstanceKey, "no-such-instance");
50
+ assertEquals(res.body.count, 0);
51
+ assertEquals(res.body.records.length, 0);
52
+ });
53
+ });
54
+
55
+ // Shared-secret guard regression coverage. Both endpoints implement the same optional guard the other
56
+ // agentic reads pin (x-hook-secret when NANO_PR_WEBHOOK_SECRET is set; unset -> open). `SECRET` is
57
+ // captured at module load, so set the env and re-import with a cache-buster to re-capture it, exactly
58
+ // as the sibling operation guard tests do. A lightweight stub engine (read-as-absence) is enough for
59
+ // the authorised path — the point is to pin 401-without-header / non-401-with-header, so a future
60
+ // refactor can't silently invert the condition or rename the header.
61
+ const stubEngine: AgentHistoryReader = {
62
+ searchAgentInstances: async () => [],
63
+ searchAgentInstanceHistory: async () => [],
64
+ getAgentInstance: async () => null,
65
+ };
66
+ const guardApp = { log: noopLog(), engine: stubEngine } as unknown as AppApi;
67
+
68
+ function guardInput(headers: Record<string, string>, params: Record<string, string> = {}) {
69
+ return {
70
+ req: { method: "GET", headers: new Headers(headers), text: async () => "" } as never,
71
+ params,
72
+ query: {},
73
+ body: undefined,
74
+ };
75
+ }
76
+
77
+ test("listAgentInstances: shared-secret guard rejects a missing/invalid secret, admits the correct one", async () => {
78
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
79
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
80
+ try {
81
+ const mod = await import(`./listAgentInstances.ts?guard=${Date.now()}`);
82
+ const handler = mod.default as (i: ReturnType<typeof guardInput>, app: AppApi) => Promise<{ status: number }>;
83
+ assertEquals((await handler(guardInput({}), guardApp)).status, 401);
84
+ assertEquals((await handler(guardInput({ "x-hook-secret": "wrong" }), guardApp)).status, 401);
85
+ assertEquals((await handler(guardInput({ "x-hook-secret": "s3cr3t" }), guardApp)).status, 200);
86
+ } finally {
87
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
88
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
89
+ }
90
+ });
91
+
92
+ test("getAgentInstanceHistory: shared-secret guard rejects a missing/invalid secret, admits the correct one", async () => {
93
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
94
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
95
+ try {
96
+ const mod = await import(`./getAgentInstanceHistory.ts?guard=${Date.now()}`);
97
+ const handler = mod.default as (i: ReturnType<typeof guardInput>, app: AppApi) => Promise<{ status: number }>;
98
+ const params = { agentInstanceKey: "ai-1" };
99
+ assertEquals((await handler(guardInput({}, params), guardApp)).status, 401);
100
+ assertEquals((await handler(guardInput({ "x-hook-secret": "wrong" }, params), guardApp)).status, 401);
101
+ assertEquals((await handler(guardInput({ "x-hook-secret": "s3cr3t" }, params), guardApp)).status, 200);
102
+ } finally {
103
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
104
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
105
+ }
106
+ });
@@ -0,0 +1,37 @@
1
+ // GET /app/api/agentic/agent-instances/{agentInstanceKey}/history → operationId `getAgentInstanceHistory`
2
+ // (issue #745/#747, umbrella #746).
3
+ //
4
+ // Fetch ONE AgentInstance's durable conversation history (turns + per-turn metrics) from the engine read
5
+ // model through the single engine-read seam — `@nanobpm/urban`'s `EngineClient.searchAgentInstanceHistory`
6
+ // / `getAgentInstance` (added in urban 0.93 / nanobpm/nano-ide#563). The cockpit renders the HISTORICAL
7
+ // transcript + metrics from this, keyed by `agentInstanceKey` — NEVER the slash-bearing relay stream id
8
+ // (#744 moot). The token-granular relay stays the LIVE overlay only.
9
+ //
10
+ // Advisory read-only (ADR 0056): observes the engine read model, never gates control flow. Read-as-absence
11
+ // — a blank/unknown key, or an engine with no AgentHistory channel (the testkit WASM double), yields an
12
+ // empty history (200), never an error. Shared-secret guard mirrors the other agentic reads.
13
+
14
+ import { type AgentHistoryQuery, readAgentHistory } from "../app/agentic/agent-history.ts";
15
+ import { envVar } from "../app/version.ts";
16
+ import { defineOperation } from "../nano-generated/operations.ts";
17
+
18
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
19
+
20
+ export default defineOperation("getAgentInstanceHistory", async ({ params, query, req }, app) => {
21
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
22
+ app.log.warn("getAgentInstanceHistory rejected: missing/invalid shared secret");
23
+ return { status: 401, body: { error: "unauthorized" } };
24
+ }
25
+ if (!app.engine) {
26
+ app.log.warn("getAgentInstanceHistory: no engine client configured — no agent-history read path");
27
+ return { status: 503, body: { error: "no engine read path available" } };
28
+ }
29
+
30
+ const filter: AgentHistoryQuery = {
31
+ ...(query.role !== undefined ? { role: query.role } : {}),
32
+ ...(query.loopIteration !== undefined ? { loopIteration: query.loopIteration } : {}),
33
+ ...(query.elementInstanceKey !== undefined ? { elementInstanceKey: query.elementInstanceKey } : {}),
34
+ };
35
+ const body = await readAgentHistory(app.engine, params.agentInstanceKey, filter);
36
+ return { status: 200, body };
37
+ });
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.183.2",
3
+ "version": "0.184.1",
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
  /**