@nanobpm/nano-workforce 0.183.2 → 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 +6 -0
- package/app/agentic/agent-history.test.ts +165 -0
- package/app/agentic/agent-history.ts +211 -0
- 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/contracts.ts +9 -1
- 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/listAgentInstances.ts +42 -0
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +104 -0
- package/pages/cockpit/mount.js +286 -3
|
@@ -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.
|
|
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
|
/**
|