@nanobpm/nano-workforce 0.183.1 → 0.184.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/agentic/agent-history.test.ts +165 -0
- package/app/agentic/agent-history.ts +211 -0
- package/app/agentic/claim-registry.test.ts +3 -2
- package/app/agentic/claim-registry.ts +8 -6
- package/app/agentic/cockpit/agent-history-render.test.ts +85 -0
- package/app/agentic/cockpit/agent-history-render.ts +168 -0
- package/app/agentic/cockpit/agent-history-view.test.ts +104 -0
- package/app/agentic/cockpit/agent-history-view.ts +186 -0
- package/app/agentic/cockpit/index.ts +21 -0
- package/app/agentic/cockpit/mount.test.ts +133 -1
- package/app/agentic/cockpit/supply-boot-agent-history.test.ts +144 -0
- package/app/agentic/cockpit/supply-boot.ts +134 -1
- package/app/agentic/cockpit/supply-view.ts +3 -3
- package/app/agentic/cockpit/transcript-view.ts +1 -1
- package/app/agentic/correlation-store.test.ts +14 -10
- package/app/agentic/correlation-store.ts +4 -3
- package/app/agentic/correlation.test.ts +24 -16
- package/app/agentic/correlation.ts +25 -12
- package/app/agentic/families/claim.family.test.ts +2 -1
- package/app/agentic/families/relay.family.test.ts +74 -73
- package/app/agentic/families/relay.family.ts +24 -21
- package/app/agentic/transcript-read.test.ts +108 -17
- package/app/agentic/transcript-read.ts +41 -7
- package/app/contracts.ts +10 -2
- package/app/mcpToolSurface.ts +8 -1
- package/db/migrations/101_agentic_history_read_expand.sql +34 -0
- package/openapi.yaml +360 -0
- package/operations/agentHistoryEndpoints.test.ts +106 -0
- package/operations/getAgentInstanceHistory.ts +37 -0
- package/operations/getAgenticSupply.test.ts +41 -2
- package/operations/getAgenticSupply.ts +7 -5
- package/operations/getAgenticTranscript.test.ts +5 -4
- package/operations/listAgentInstances.ts +42 -0
- package/operations/listAgenticTranscripts.test.ts +10 -9
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +104 -0
- package/pages/cockpit/mount.js +286 -3
- package/test/agentic-e2e.test.ts +4 -1
|
@@ -0,0 +1,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
|
|
82
|
-
store.flush("
|
|
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, "
|
|
93
|
-
assertEquals(t.jobKey, "6494", "the jobKey is decoded from the
|
|
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("
|
|
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("
|
|
128
|
-
store.flush("
|
|
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, "
|
|
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, "
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -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
|
|
467
|
-
*
|
|
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
|
/**
|
package/test/agentic-e2e.test.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|