@nanobpm/nano-workforce 0.69.1 → 0.70.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/AGENTS.md +18 -0
- package/CHANGELOG.md +7 -0
- package/README.md +10 -8
- package/app/agentic/cockpit/index.ts +18 -0
- package/app/agentic/cockpit/supply-boot-past.test.ts +519 -0
- package/app/agentic/cockpit/supply-boot.test.ts +34 -0
- package/app/agentic/cockpit/supply-boot.ts +256 -21
- package/app/agentic/cockpit/transcript-render.test.ts +110 -0
- package/app/agentic/cockpit/transcript-render.ts +136 -0
- package/app/agentic/cockpit/transcript-view.test.ts +61 -0
- package/app/agentic/cockpit/transcript-view.ts +131 -0
- package/app/agentic/families/relay.family.test.ts +103 -0
- package/app/agentic/families/relay.family.ts +74 -0
- package/app/agentic/transcript-read.test.ts +72 -0
- package/app/agentic/transcript-read.ts +161 -0
- package/app/blackboard.test.ts +15 -7
- package/app/blackboard.ts +4 -4
- package/openapi.yaml +267 -0
- package/operations/getAgenticTranscript.test.ts +165 -0
- package/operations/getAgenticTranscript.ts +42 -0
- package/operations/listAgenticTranscripts.test.ts +169 -0
- package/operations/listAgenticTranscripts.ts +61 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +70 -0
- package/pages/cockpit/mount.js +254 -13
- package/pages/cockpit.page.json +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Tests for GET /app/api/agentic/transcripts → operation `listAgenticTranscripts` (H3 read path, #222).
|
|
2
|
+
//
|
|
3
|
+
// Covers: the empty list when no relay/transcript family is mounted; the shared-secret guard; the
|
|
4
|
+
// end-to-end projection of a mounted TranscriptStore's rows into the list (byteLength, chunkCount,
|
|
5
|
+
// lifecycle/status, jobKey decoded from the stream id); correlation enrichment (process instance /
|
|
6
|
+
// plan) when the H6 correlation family is mounted; and the jobKey / plan / time filters.
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
|
|
10
|
+
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
11
|
+
import type { SqliteDb } from "@nanobpm/agentic/transcript";
|
|
12
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
import { currentCorrelation } from "../app/agentic/correlation.ts";
|
|
15
|
+
import { family as correlationFamily } from "../app/agentic/families/correlation.family.ts";
|
|
16
|
+
import { createRelayFamily, currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
|
|
17
|
+
import type { AgenticContext } from "../app/agentic/registry.ts";
|
|
18
|
+
import { noopLog } from "../test/log.ts";
|
|
19
|
+
import handler from "./listAgenticTranscripts.ts";
|
|
20
|
+
|
|
21
|
+
function memSqlite(): SqliteDb {
|
|
22
|
+
const db = new DatabaseSync(":memory:");
|
|
23
|
+
return {
|
|
24
|
+
exec: (sql) => db.exec(sql),
|
|
25
|
+
run: (sql, params = []) => {
|
|
26
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
27
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
28
|
+
},
|
|
29
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
30
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function memData(db: SqliteDb): DataLayer {
|
|
35
|
+
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A hub double that just captures the relay family handler (unused here — we seed the store directly). */
|
|
39
|
+
function fakeHub() {
|
|
40
|
+
return {
|
|
41
|
+
registerFamilyHandler(_family: string, _handler: (frame: Frame, conn: never) => void) {},
|
|
42
|
+
registry: { has: () => false, list: () => [] },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mountCtx(db: SqliteDb): AgenticContext {
|
|
47
|
+
const hub = fakeHub();
|
|
48
|
+
return {
|
|
49
|
+
hub: hub as never,
|
|
50
|
+
registry: hub.registry as unknown as ConnectionRegistry,
|
|
51
|
+
transport: undefined as never,
|
|
52
|
+
data: memData(db),
|
|
53
|
+
log: noopLog(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function input(query: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
|
|
58
|
+
return {
|
|
59
|
+
req: { method: "GET", path: "/app/api/agentic/transcripts", query: new URLSearchParams(), headers: new Headers(headers), text: async () => "" } as never,
|
|
60
|
+
params: {},
|
|
61
|
+
query,
|
|
62
|
+
body: undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const app = { log: noopLog() } as unknown as AppApi;
|
|
67
|
+
const relayFamily = createRelayFamily();
|
|
68
|
+
|
|
69
|
+
test("returns an empty list when no relay/transcript family is mounted", async () => {
|
|
70
|
+
relayFamily.teardown?.();
|
|
71
|
+
const res = (await handler(input(), app)) as { status: number; body: { count: number; transcripts: unknown[] } };
|
|
72
|
+
assertEquals(res.status, 200);
|
|
73
|
+
assertEquals(res.body.count, 0);
|
|
74
|
+
assertEquals(res.body.transcripts.length, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("projects the TranscriptStore rows into the list (byteLength, chunkCount, lifecycle, jobKey)", async () => {
|
|
78
|
+
relayFamily.mount(mountCtx(memSqlite()));
|
|
79
|
+
const store = currentRelayTranscriptService()?.store;
|
|
80
|
+
assert(store !== undefined, "the relay family installs a persisted store");
|
|
81
|
+
// Seed one completed ephemeral session on a jobKey-scoped stream.
|
|
82
|
+
store.flush("job:6494", { since: () => ({ entries: [{ offset: 0, chunk: "hello " }, { offset: 1, chunk: "world" }] }), nextOffset: 2 }, "ephemeral");
|
|
83
|
+
try {
|
|
84
|
+
const res = (await handler(input(), app)) as {
|
|
85
|
+
status: number;
|
|
86
|
+
body: { count: number; retentionMs?: number; transcripts: Array<Record<string, unknown>> };
|
|
87
|
+
};
|
|
88
|
+
assertEquals(res.status, 200);
|
|
89
|
+
assertEquals(res.body.count, 1);
|
|
90
|
+
assert(typeof res.body.retentionMs === "number", "the list surfaces the retention window");
|
|
91
|
+
const t = res.body.transcripts[0];
|
|
92
|
+
assertEquals(t.stream, "job:6494");
|
|
93
|
+
assertEquals(t.jobKey, "6494", "the jobKey is decoded from the job: stream id");
|
|
94
|
+
assertEquals(t.lifecycle, "ephemeral");
|
|
95
|
+
assertEquals(t.status, "completed");
|
|
96
|
+
assertEquals(t.chunkCount, 2);
|
|
97
|
+
assertEquals(t.byteLength, "hello world".length);
|
|
98
|
+
assertEquals(t.nextOffset, 2);
|
|
99
|
+
} finally {
|
|
100
|
+
relayFamily.teardown?.();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("enriches with the H6 correlation (process instance / plan) when it is still live", async () => {
|
|
105
|
+
relayFamily.mount(mountCtx(memSqlite()));
|
|
106
|
+
const store = currentRelayTranscriptService()?.store;
|
|
107
|
+
assert(store !== undefined);
|
|
108
|
+
store.flush("job:6494", { since: () => ({ entries: [{ offset: 0, chunk: "x" }] }), nextOffset: 1 }, "ephemeral");
|
|
109
|
+
correlationFamily.mount({ hub: undefined as never, registry: undefined as never, transport: undefined as never, data: undefined, log: noopLog() });
|
|
110
|
+
currentCorrelation()?.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", planKey: "o/r#142" });
|
|
111
|
+
try {
|
|
112
|
+
const res = (await handler(input(), app)) as { body: { transcripts: Array<Record<string, unknown>> } };
|
|
113
|
+
const t = res.body.transcripts[0];
|
|
114
|
+
assertEquals(t.processInstanceKey, "4612");
|
|
115
|
+
assertEquals(t.bpmnProcessId, "plan-fanout");
|
|
116
|
+
assertEquals(t.planKey, "o/r#142");
|
|
117
|
+
} finally {
|
|
118
|
+
correlationFamily.teardown?.();
|
|
119
|
+
relayFamily.teardown?.();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("filters by jobKey and plan", async () => {
|
|
124
|
+
relayFamily.mount(mountCtx(memSqlite()));
|
|
125
|
+
const store = currentRelayTranscriptService()?.store;
|
|
126
|
+
assert(store !== undefined);
|
|
127
|
+
store.flush("job:1", { since: () => ({ entries: [{ offset: 0, chunk: "a" }] }), nextOffset: 1 }, "ephemeral");
|
|
128
|
+
store.flush("job:2", { since: () => ({ entries: [{ offset: 0, chunk: "b" }] }), nextOffset: 1 }, "ephemeral");
|
|
129
|
+
correlationFamily.mount({ hub: undefined as never, registry: undefined as never, transport: undefined as never, data: undefined, log: noopLog() });
|
|
130
|
+
currentCorrelation()?.link("wk", "2", { planKey: "o/r#9" });
|
|
131
|
+
try {
|
|
132
|
+
const byJob = (await handler(input({ jobKey: "1" }), app)) as { body: { count: number; transcripts: Array<Record<string, unknown>> } };
|
|
133
|
+
assertEquals(byJob.body.count, 1);
|
|
134
|
+
assertEquals(byJob.body.transcripts[0]?.stream, "job:1");
|
|
135
|
+
|
|
136
|
+
const byPlan = (await handler(input({ planKey: "o/r#9" }), app)) as { body: { count: number; transcripts: Array<Record<string, unknown>> } };
|
|
137
|
+
assertEquals(byPlan.body.count, 1);
|
|
138
|
+
assertEquals(byPlan.body.transcripts[0]?.stream, "job:2");
|
|
139
|
+
} finally {
|
|
140
|
+
correlationFamily.teardown?.();
|
|
141
|
+
relayFamily.teardown?.();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("rejects a malformed since/until with a 400", async () => {
|
|
146
|
+
relayFamily.mount(mountCtx(memSqlite()));
|
|
147
|
+
try {
|
|
148
|
+
const res = (await handler(input({ since: "not-a-date" }), app)) as { status: number };
|
|
149
|
+
assertEquals(res.status, 400);
|
|
150
|
+
} finally {
|
|
151
|
+
relayFamily.teardown?.();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("shared-secret guard rejects a missing secret when configured", async () => {
|
|
156
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
157
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
158
|
+
try {
|
|
159
|
+
const mod = await import(`./listAgenticTranscripts.ts?guard=${Date.now()}`);
|
|
160
|
+
const guarded = mod.default as typeof handler;
|
|
161
|
+
const bad = (await guarded(input(), app)) as { status: number };
|
|
162
|
+
assertEquals(bad.status, 401);
|
|
163
|
+
const ok = (await guarded(input({}, { "x-hook-secret": "s3cr3t" }), app)) as { status: number };
|
|
164
|
+
assertEquals(ok.status, 200);
|
|
165
|
+
} finally {
|
|
166
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
167
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// GET /app/api/agentic/transcripts → operationId `listAgenticTranscripts` (ADR 0056, H3 read path #222).
|
|
2
|
+
//
|
|
3
|
+
// The READ counterpart to the write-only transcript store (H3 #146): it lists the durable transcripts an
|
|
4
|
+
// ephemeral agent flushed on job completion, so an operator can review "what did that agent do" AFTER it
|
|
5
|
+
// is gone. Sourced from the mounted relay/transcript service's TranscriptStore (over `app.data`) and
|
|
6
|
+
// correlated via `app/agentic/correlation.ts` (best-effort — jobKey is always recovered from the stream
|
|
7
|
+
// id, engine context only while the job is still live). Feeds the cockpit "past sessions" view.
|
|
8
|
+
//
|
|
9
|
+
// Advisory read-only (ADR 0056): it NEVER gates a BPMN sequence flow. Optional filters (jobKey / process
|
|
10
|
+
// instance / plan / time) narrow the feed. The optional shared-secret guard mirrors getAgenticSupply:
|
|
11
|
+
// when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header; unset -> open.
|
|
12
|
+
|
|
13
|
+
import { currentCorrelation } from "../app/agentic/correlation.ts";
|
|
14
|
+
import { currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
|
|
15
|
+
import { listTranscripts, type TranscriptFilter } from "../app/agentic/transcript-read.ts";
|
|
16
|
+
import { envVar } from "../app/version.ts";
|
|
17
|
+
import type { AgenticTranscriptList } from "../nano-generated/api-io.d.ts";
|
|
18
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
19
|
+
|
|
20
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
21
|
+
|
|
22
|
+
/** Reject an ISO-8601 filter that does not parse (a malformed since/until is a 400, not a silent no-op). */
|
|
23
|
+
function badInstant(value: string | undefined): boolean {
|
|
24
|
+
return value !== undefined && !Number.isFinite(Date.parse(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default defineOperation("listAgenticTranscripts", async ({ query, req }, app) => {
|
|
28
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
29
|
+
app.log.warn("listAgenticTranscripts rejected: missing/invalid shared secret");
|
|
30
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (badInstant(query.since) || badInstant(query.until)) {
|
|
34
|
+
return { status: 400, body: { error: "invalid since/until: expected an ISO-8601 instant" } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const service = currentRelayTranscriptService();
|
|
38
|
+
const store = service?.store;
|
|
39
|
+
if (!store) {
|
|
40
|
+
// The relay family has not mounted, or is running unpersisted (no DataLayer) - no transcripts to
|
|
41
|
+
// report, not an error (advisory).
|
|
42
|
+
const empty: AgenticTranscriptList = { count: 0, generatedAt: new Date().toISOString(), transcripts: [] };
|
|
43
|
+
return { status: 200, body: empty };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const filter: TranscriptFilter = {
|
|
47
|
+
...(query.jobKey !== undefined ? { jobKey: query.jobKey } : {}),
|
|
48
|
+
...(query.processInstanceKey !== undefined ? { processInstanceKey: query.processInstanceKey } : {}),
|
|
49
|
+
...(query.planKey !== undefined ? { planKey: query.planKey } : {}),
|
|
50
|
+
...(query.since !== undefined ? { since: query.since } : {}),
|
|
51
|
+
...(query.until !== undefined ? { until: query.until } : {}),
|
|
52
|
+
};
|
|
53
|
+
const transcripts = listTranscripts(store, currentCorrelation(), filter);
|
|
54
|
+
const body: AgenticTranscriptList = {
|
|
55
|
+
count: transcripts.length,
|
|
56
|
+
generatedAt: new Date().toISOString(),
|
|
57
|
+
retentionMs: store.ephemeralRetentionMs,
|
|
58
|
+
transcripts,
|
|
59
|
+
};
|
|
60
|
+
return { status: 200, body };
|
|
61
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
.cockpit-supply-region,
|
|
45
|
+
.cockpit-past-region,
|
|
45
46
|
.cockpit-terminal {
|
|
46
47
|
background: var(--cockpit-panel);
|
|
47
48
|
border: 1px solid var(--cockpit-edge);
|
|
@@ -160,3 +161,72 @@
|
|
|
160
161
|
background: #05080b;
|
|
161
162
|
border-radius: 6px;
|
|
162
163
|
}
|
|
164
|
+
|
|
165
|
+
/* ── Past sessions (H3 read path / #222): the captured-session history + replay. ──────────────── */
|
|
166
|
+
|
|
167
|
+
.cockpit-past-header {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-wrap: wrap;
|
|
170
|
+
align-items: baseline;
|
|
171
|
+
justify-content: space-between;
|
|
172
|
+
gap: 8px;
|
|
173
|
+
margin-bottom: 8px;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.cockpit-past-title {
|
|
177
|
+
font-size: 13px;
|
|
178
|
+
margin: 0;
|
|
179
|
+
color: var(--cockpit-muted);
|
|
180
|
+
text-transform: uppercase;
|
|
181
|
+
letter-spacing: 0.04em;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.cockpit-past-summary {
|
|
185
|
+
color: var(--cockpit-muted);
|
|
186
|
+
font-size: 12px;
|
|
187
|
+
font-variant-numeric: tabular-nums;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.cockpit-past-table {
|
|
191
|
+
width: 100%;
|
|
192
|
+
border-collapse: collapse;
|
|
193
|
+
font-variant-numeric: tabular-nums;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.cockpit-past-replay {
|
|
197
|
+
background: none;
|
|
198
|
+
border: none;
|
|
199
|
+
color: var(--cockpit-text);
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
font: inherit;
|
|
202
|
+
padding: 0;
|
|
203
|
+
text-align: left;
|
|
204
|
+
text-decoration: underline;
|
|
205
|
+
text-underline-offset: 2px;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.cockpit-past-replay:hover { color: #58a6ff; }
|
|
209
|
+
|
|
210
|
+
.cockpit-past-session[data-active="true"] {
|
|
211
|
+
background: rgba(88, 166, 255, 0.12);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.cockpit-past-status { color: var(--cockpit-muted); }
|
|
215
|
+
.cockpit-past-size { color: var(--cockpit-muted); }
|
|
216
|
+
.cockpit-past-captured { color: var(--cockpit-muted); font-size: 12px; }
|
|
217
|
+
|
|
218
|
+
.cockpit-past-empty {
|
|
219
|
+
color: var(--cockpit-muted);
|
|
220
|
+
padding: 8px 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* Distinguish a live terminal from a replayed (static) past session at a glance. */
|
|
224
|
+
.cockpit-terminal[data-terminal-mode="replay"] {
|
|
225
|
+
border-color: #8957e5;
|
|
226
|
+
}
|
|
227
|
+
.cockpit-terminal[data-terminal-mode="replay"] .cockpit-panel-title {
|
|
228
|
+
color: #b392f0;
|
|
229
|
+
}
|
|
230
|
+
.cockpit-terminal[data-terminal-mode="live"] .cockpit-panel-title {
|
|
231
|
+
color: var(--cockpit-green);
|
|
232
|
+
}
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -21,6 +21,7 @@ import { Terminal } from "@xterm/xterm";
|
|
|
21
21
|
|
|
22
22
|
const DEFAULT_REFRESH_MS = 2000;
|
|
23
23
|
const DEFAULT_STALE_AFTER_MS = 15_000;
|
|
24
|
+
const DEFAULT_PAST_FETCH_TIMEOUT_MS = 15_000;
|
|
24
25
|
|
|
25
26
|
function isPosInt(value) {
|
|
26
27
|
return Number.isSafeInteger(value) && value > 0;
|
|
@@ -181,6 +182,113 @@ function renderSupply(host, doc, view, onDrill) {
|
|
|
181
182
|
host.appendChild(root);
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// ── past-sessions projection + render (mirrors app/agentic/cockpit/transcript-view.ts + -render.ts) ──
|
|
186
|
+
|
|
187
|
+
function humanBytes(bytes) {
|
|
188
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
|
|
189
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
190
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
191
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function humanDuration(ms) {
|
|
195
|
+
if (ms == null || !Number.isFinite(ms) || ms <= 0) return undefined;
|
|
196
|
+
const s = Math.round(ms / 1000);
|
|
197
|
+
if (s < 60) return `${s}s`;
|
|
198
|
+
const m = Math.round(s / 60);
|
|
199
|
+
if (m < 60) return `${m}m`;
|
|
200
|
+
const h = Math.round(m / 60);
|
|
201
|
+
if (h < 48) return `${h}h`;
|
|
202
|
+
return `${Math.round(h / 24)}d`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sessionLabel(t) {
|
|
206
|
+
const parts = [];
|
|
207
|
+
if (t.bpmnProcessId != null) parts.push(t.bpmnProcessId);
|
|
208
|
+
if (t.elementId != null) parts.push(t.elementId);
|
|
209
|
+
if (t.processInstanceKey != null) parts.push(`inst ${t.processInstanceKey}`);
|
|
210
|
+
if (t.planKey != null) parts.push(t.planKey);
|
|
211
|
+
if (parts.length > 0) return parts.join(" \u00b7 ");
|
|
212
|
+
if (t.jobKey != null) return `job ${t.jobKey}`;
|
|
213
|
+
return t.stream;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function transcriptsView(report) {
|
|
217
|
+
const sessions = (report.transcripts ?? [])
|
|
218
|
+
.map((t) => ({
|
|
219
|
+
stream: t.stream,
|
|
220
|
+
label: sessionLabel(t),
|
|
221
|
+
jobKey: t.jobKey,
|
|
222
|
+
status: t.status,
|
|
223
|
+
lifecycle: t.lifecycle,
|
|
224
|
+
size: humanBytes(t.byteLength),
|
|
225
|
+
byteLength: t.byteLength,
|
|
226
|
+
capturedAt: t.completedAt ?? t.createdAt,
|
|
227
|
+
}))
|
|
228
|
+
.sort((a, b) => {
|
|
229
|
+
const byTime = String(b.capturedAt).localeCompare(String(a.capturedAt));
|
|
230
|
+
return byTime !== 0 ? byTime : a.stream.localeCompare(b.stream);
|
|
231
|
+
});
|
|
232
|
+
return { sessions, count: sessions.length, retention: humanDuration(report.retentionMs) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sessionRow(doc, session, onReplay, activeStream) {
|
|
236
|
+
const row = el(doc, "tr", "cockpit-past-session");
|
|
237
|
+
row.setAttribute("data-stream", session.stream);
|
|
238
|
+
row.setAttribute("data-status", session.status);
|
|
239
|
+
if (session.jobKey != null) row.setAttribute("data-job-key", session.jobKey);
|
|
240
|
+
if (activeStream === session.stream) row.setAttribute("data-active", "true");
|
|
241
|
+
const nameCell = el(doc, "td", "cockpit-td cockpit-past-name");
|
|
242
|
+
const button = el(doc, "button", "cockpit-past-replay", session.label);
|
|
243
|
+
button.setAttribute("type", "button");
|
|
244
|
+
button.setAttribute("data-stream", session.stream);
|
|
245
|
+
if (onReplay) button.addEventListener("click", () => onReplay(session.stream));
|
|
246
|
+
nameCell.appendChild(button);
|
|
247
|
+
row.appendChild(nameCell);
|
|
248
|
+
row.appendChild(el(doc, "td", "cockpit-td cockpit-past-status", session.status));
|
|
249
|
+
row.appendChild(el(doc, "td", "cockpit-td cockpit-past-size", session.size));
|
|
250
|
+
row.appendChild(el(doc, "td", "cockpit-td cockpit-past-captured", session.capturedAt));
|
|
251
|
+
return row;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderTranscripts(host, doc, view, onReplay, activeStream) {
|
|
255
|
+
host.replaceChildren();
|
|
256
|
+
const root = el(doc, "div", "cockpit-past");
|
|
257
|
+
root.setAttribute("data-session-count", String(view.count));
|
|
258
|
+
const header = el(doc, "header", "cockpit-past-header");
|
|
259
|
+
header.appendChild(el(doc, "h2", "cockpit-past-title", "Past sessions"));
|
|
260
|
+
const summary = el(doc, "span", "cockpit-past-summary", view.retention != null ? `${view.count} \u00b7 kept ${view.retention}` : `${view.count}`);
|
|
261
|
+
summary.setAttribute("data-summary", "past");
|
|
262
|
+
header.appendChild(summary);
|
|
263
|
+
root.appendChild(header);
|
|
264
|
+
if (view.count === 0) {
|
|
265
|
+
const empty = el(doc, "div", "cockpit-past-empty", "No captured sessions yet.");
|
|
266
|
+
empty.setAttribute("data-empty", "true");
|
|
267
|
+
root.appendChild(empty);
|
|
268
|
+
host.appendChild(root);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const table = el(doc, "table", "cockpit-past-table");
|
|
272
|
+
const thead = el(doc, "thead", "cockpit-past-thead");
|
|
273
|
+
const head = el(doc, "tr", "cockpit-past-head");
|
|
274
|
+
for (const label of ["session", "status", "size", "captured"]) head.appendChild(el(doc, "th", "cockpit-th", label));
|
|
275
|
+
thead.appendChild(head);
|
|
276
|
+
table.appendChild(thead);
|
|
277
|
+
const tbody = el(doc, "tbody", "cockpit-past-tbody");
|
|
278
|
+
for (const session of view.sessions) tbody.appendChild(sessionRow(doc, session, onReplay, activeStream));
|
|
279
|
+
table.appendChild(tbody);
|
|
280
|
+
root.appendChild(table);
|
|
281
|
+
host.appendChild(root);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Feed a fetched transcript's stored chunks through a resume-from-offset TerminalSession (static playback). */
|
|
285
|
+
function replayTranscript(session, data) {
|
|
286
|
+
session.handle({ op: "subscribed", stream: data.stream, gap: data.gap, nextOffset: data.nextOffset });
|
|
287
|
+
for (const entry of data.entries ?? []) {
|
|
288
|
+
session.handle({ stream: data.stream, offset: entry.offset, chunk: entry.chunk });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
184
292
|
// ── boot orchestration (mirrors app/agentic/cockpit/supply-boot.ts) ────────────────────────────
|
|
185
293
|
|
|
186
294
|
/** An xterm.js-backed terminal sink mounted into `host`. */
|
|
@@ -219,6 +327,10 @@ function relaySocketFactory(url) {
|
|
|
219
327
|
* @param {number} [opts.refreshMs] — poll interval (default 2000).
|
|
220
328
|
* @param {number} [opts.staleAfterMs] — a worker is rendered "stale" once its last heartbeat is at
|
|
221
329
|
* least this many ms old (default 15000).
|
|
330
|
+
* @param {number} [opts.pastFetchTimeoutMs] — upper bound (ms) on a single past-sessions transcripts
|
|
331
|
+
* fetch; the fetch is aborted past this so a hung endpoint can't wedge the past panel (default 15000).
|
|
332
|
+
* @param {string} [opts.transcriptsUrl] — the captured-session list endpoint (default
|
|
333
|
+
* /app/api/agentic/transcripts) backing the always-on "past sessions" history + replay.
|
|
222
334
|
* @returns a handle with `.dispose()`.
|
|
223
335
|
*/
|
|
224
336
|
export function mountCockpit(host, opts = {}) {
|
|
@@ -229,6 +341,7 @@ export function mountCockpit(host, opts = {}) {
|
|
|
229
341
|
}
|
|
230
342
|
const doc = document;
|
|
231
343
|
const reportUrl = opts.reportUrl ?? "/app/api/agentic/supply";
|
|
344
|
+
const transcriptsUrl = opts.transcriptsUrl ?? "/app/api/agentic/transcripts";
|
|
232
345
|
const hookSecret = opts.hookSecret;
|
|
233
346
|
const relayUrl = opts.relayUrl ?? defaultRelayUrl(opts.relayToken, opts.relayCapability);
|
|
234
347
|
const refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
|
|
@@ -239,20 +352,40 @@ export function mountCockpit(host, opts = {}) {
|
|
|
239
352
|
throw new RangeError(`mountCockpit(opts.refreshMs): must be a positive safe integer, got ${refreshMs}.`);
|
|
240
353
|
}
|
|
241
354
|
const staleAfterMs = opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
355
|
+
// Upper bound on a single "past sessions" transcripts fetch. refreshPast() is single-flight, so a
|
|
356
|
+
// fetch that HANGS (never settles) would otherwise leave `pastRefreshing` stuck true forever and
|
|
357
|
+
// permanently disable the past panel; a bounded (aborting) fetch clears the flag so the next poll retries.
|
|
358
|
+
const pastFetchTimeoutMs = opts.pastFetchTimeoutMs ?? DEFAULT_PAST_FETCH_TIMEOUT_MS;
|
|
359
|
+
if (!isPosInt(pastFetchTimeoutMs)) {
|
|
360
|
+
throw new RangeError(
|
|
361
|
+
`mountCockpit(opts.pastFetchTimeoutMs): must be a positive safe integer, got ${pastFetchTimeoutMs}.`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
242
364
|
const connectRelay = relaySocketFactory(relayUrl);
|
|
243
365
|
const onError = (err) => console.error("[cockpit]", err);
|
|
244
366
|
|
|
245
|
-
|
|
246
|
-
|
|
367
|
+
const jsonHeaders = () => {
|
|
368
|
+
const headers = { accept: "application/json" };
|
|
369
|
+
if (hookSecret) headers["x-hook-secret"] = hookSecret;
|
|
370
|
+
return headers;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// Stable skeleton: a volatile supply-list region + a volatile "past sessions" region the poll
|
|
374
|
+
// re-renders, and a PERSISTENT terminal region a refresh never touches (so a drilled-in/replayed
|
|
375
|
+
// terminal survives a list refresh). The terminal panel title distinguishes live vs replayed.
|
|
247
376
|
host.replaceChildren();
|
|
248
377
|
const shell = el(doc, "div", "cockpit-shell");
|
|
249
378
|
const listRegion = el(doc, "div", "cockpit-supply-region");
|
|
379
|
+
const pastRegion = el(doc, "div", "cockpit-past-region");
|
|
250
380
|
const terminalPanel = el(doc, "section", "cockpit-terminal");
|
|
251
|
-
terminalPanel.
|
|
381
|
+
terminalPanel.setAttribute("data-terminal-mode", "idle");
|
|
382
|
+
const terminalTitle = el(doc, "h2", "cockpit-panel-title", "Worker terminal");
|
|
383
|
+
terminalPanel.appendChild(terminalTitle);
|
|
252
384
|
const terminalHost = el(doc, "div", "cockpit-terminal-host");
|
|
253
385
|
terminalHost.setAttribute("data-terminal", "host");
|
|
254
386
|
terminalPanel.appendChild(terminalHost);
|
|
255
387
|
shell.appendChild(listRegion);
|
|
388
|
+
shell.appendChild(pastRegion);
|
|
256
389
|
shell.appendChild(terminalPanel);
|
|
257
390
|
host.appendChild(shell);
|
|
258
391
|
|
|
@@ -262,13 +395,36 @@ export function mountCockpit(host, opts = {}) {
|
|
|
262
395
|
let generation = 0;
|
|
263
396
|
let drill; // { stream, client }
|
|
264
397
|
let terminal; // the current xterm sink
|
|
398
|
+
let mode; // "live" | "replay" | undefined
|
|
399
|
+
let shownStream;
|
|
400
|
+
// Bumped by every drillInto()/replayInto()/dispose() that claims the terminal region, so a slow
|
|
401
|
+
// replay fetch that resolves after a newer selection drops its result instead of clobbering it.
|
|
402
|
+
let opToken = 0;
|
|
403
|
+
// True while a refreshPast() fetch is outstanding, so the supply poll never stacks past-fetches
|
|
404
|
+
// against a slow/hung transcripts endpoint.
|
|
405
|
+
let pastRefreshing = false;
|
|
265
406
|
|
|
266
|
-
function
|
|
267
|
-
|
|
407
|
+
function setMode(next, stream) {
|
|
408
|
+
mode = next;
|
|
409
|
+
shownStream = stream;
|
|
410
|
+
terminalPanel.setAttribute("data-terminal-mode", next ?? "idle");
|
|
411
|
+
if (next === "live") terminalTitle.textContent = "Worker terminal — live";
|
|
412
|
+
else if (next === "replay") terminalTitle.textContent = "Worker terminal — replay (past session)";
|
|
413
|
+
else terminalTitle.textContent = "Worker terminal";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function teardownTerminal() {
|
|
268
417
|
drill?.client.close();
|
|
269
418
|
drill = undefined;
|
|
270
419
|
terminal?.dispose?.();
|
|
271
420
|
terminal = undefined;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function drillInto(stream) {
|
|
424
|
+
if (disposed || (mode === "live" && drill?.stream === stream)) return;
|
|
425
|
+
// Claim the terminal region: bump the op token so an in-flight replay drops its stale result.
|
|
426
|
+
opToken++;
|
|
427
|
+
teardownTerminal();
|
|
272
428
|
try {
|
|
273
429
|
terminalHost.replaceChildren();
|
|
274
430
|
const sink = xtermSink(terminalHost);
|
|
@@ -283,18 +439,102 @@ export function mountCockpit(host, opts = {}) {
|
|
|
283
439
|
session = new TerminalSession({ stream, sink, send: (message) => client.sendRelay(message) });
|
|
284
440
|
client.open();
|
|
285
441
|
drill = { stream, client };
|
|
442
|
+
setMode("live", stream);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
// The new terminal failed to build after the prior one was torn down: reset the region to idle
|
|
445
|
+
// (and drop any partially-built terminal) so the UI never shows a stale "live"/"replay"
|
|
446
|
+
// indicator with nothing behind it — symmetric with replayInto(), which clears mode up-front.
|
|
447
|
+
teardownTerminal();
|
|
448
|
+
setMode(undefined, undefined);
|
|
449
|
+
onError(err);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function replayInto(stream) {
|
|
454
|
+
if (disposed) return;
|
|
455
|
+
// Claim the terminal region under a fresh op token, captured for the post-fetch re-check below.
|
|
456
|
+
const token = ++opToken;
|
|
457
|
+
// Drop any live drill + prior terminal before fetching so replay never overlaps a live stream.
|
|
458
|
+
teardownTerminal();
|
|
459
|
+
setMode(undefined, undefined);
|
|
460
|
+
let data;
|
|
461
|
+
try {
|
|
462
|
+
// Bound the fetch: a transcript endpoint that never responds would otherwise leave replay() pending
|
|
463
|
+
// forever with an in-flight request and the terminal wedged out of live mode. Abort after
|
|
464
|
+
// pastFetchTimeoutMs so the fetch always settles (here, rejects) and this catch leaves mode idle.
|
|
465
|
+
const controller = new AbortController();
|
|
466
|
+
const abortTimer = setTimeout(() => controller.abort(), pastFetchTimeoutMs);
|
|
467
|
+
abortTimer.unref?.();
|
|
468
|
+
let res;
|
|
469
|
+
try {
|
|
470
|
+
res = await fetch(`${transcriptsUrl}/${encodeURIComponent(stream)}`, { headers: jsonHeaders(), signal: controller.signal });
|
|
471
|
+
} finally {
|
|
472
|
+
clearTimeout(abortTimer);
|
|
473
|
+
}
|
|
474
|
+
if (!res.ok) throw new Error(`transcript fetch failed: ${res.status}`);
|
|
475
|
+
data = await res.json();
|
|
476
|
+
} catch (err) {
|
|
477
|
+
onError(err);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// A newer drill/replay (or dispose) claimed the terminal while this fetch was outstanding — drop
|
|
481
|
+
// the stale result rather than overwrite the newer selection with an out-of-date replay.
|
|
482
|
+
if (disposed || token !== opToken) return;
|
|
483
|
+
try {
|
|
484
|
+
terminalHost.replaceChildren();
|
|
485
|
+
const sink = xtermSink(terminalHost);
|
|
486
|
+
terminal = sink;
|
|
487
|
+
const session = new TerminalSession({ stream, sink, send: () => {}, from: data.from ?? 0 });
|
|
488
|
+
replayTranscript(session, data);
|
|
489
|
+
setMode("replay", stream);
|
|
490
|
+
void refreshPast();
|
|
286
491
|
} catch (err) {
|
|
287
492
|
onError(err);
|
|
288
493
|
}
|
|
289
494
|
}
|
|
290
495
|
|
|
496
|
+
async function refreshPast() {
|
|
497
|
+
// Single-flight: while one past-fetch is outstanding (including a hung one), skip starting another
|
|
498
|
+
// so the supply poll can't stack pending fetches against a slow/unresponsive transcripts endpoint.
|
|
499
|
+
if (pastRefreshing) return;
|
|
500
|
+
pastRefreshing = true;
|
|
501
|
+
try {
|
|
502
|
+
let report;
|
|
503
|
+
try {
|
|
504
|
+
// Bound the fetch: refreshPast() is single-flight, so a transcripts endpoint that never responds
|
|
505
|
+
// would otherwise wedge `pastRefreshing` true forever. Abort after pastFetchTimeoutMs so the fetch
|
|
506
|
+
// always settles (here, rejects), the finally clears the flag, and the next poll can retry.
|
|
507
|
+
const controller = new AbortController();
|
|
508
|
+
const abortTimer = setTimeout(() => controller.abort(), pastFetchTimeoutMs);
|
|
509
|
+
abortTimer.unref?.();
|
|
510
|
+
let res;
|
|
511
|
+
try {
|
|
512
|
+
res = await fetch(transcriptsUrl, { headers: jsonHeaders(), signal: controller.signal });
|
|
513
|
+
} finally {
|
|
514
|
+
clearTimeout(abortTimer);
|
|
515
|
+
}
|
|
516
|
+
if (!res.ok) throw new Error(`transcripts fetch failed: ${res.status}`);
|
|
517
|
+
report = await res.json();
|
|
518
|
+
} catch (err) {
|
|
519
|
+
onError(err);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (disposed) return;
|
|
523
|
+
try {
|
|
524
|
+
renderTranscripts(pastRegion, doc, transcriptsView(report), replayInto, mode === "replay" ? shownStream : undefined);
|
|
525
|
+
} catch (err) {
|
|
526
|
+
onError(err);
|
|
527
|
+
}
|
|
528
|
+
} finally {
|
|
529
|
+
pastRefreshing = false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
291
533
|
async function refresh() {
|
|
292
534
|
if (disposed) return;
|
|
293
535
|
let report;
|
|
294
536
|
try {
|
|
295
|
-
const
|
|
296
|
-
if (hookSecret) headers["x-hook-secret"] = hookSecret;
|
|
297
|
-
const res = await fetch(reportUrl, { headers });
|
|
537
|
+
const res = await fetch(reportUrl, { headers: jsonHeaders() });
|
|
298
538
|
if (!res.ok) throw new Error(`supply fetch failed: ${res.status}`);
|
|
299
539
|
report = await res.json();
|
|
300
540
|
} catch (err) {
|
|
@@ -307,6 +547,8 @@ export function mountCockpit(host, opts = {}) {
|
|
|
307
547
|
} catch (err) {
|
|
308
548
|
onError(err);
|
|
309
549
|
}
|
|
550
|
+
// Fire-and-forget: a hung transcripts endpoint must never stall the supply poll's next tick.
|
|
551
|
+
void refreshPast();
|
|
310
552
|
}
|
|
311
553
|
|
|
312
554
|
function tick(gen) {
|
|
@@ -332,15 +574,14 @@ export function mountCockpit(host, opts = {}) {
|
|
|
332
574
|
function dispose() {
|
|
333
575
|
if (disposed) return;
|
|
334
576
|
disposed = true;
|
|
577
|
+
opToken++;
|
|
335
578
|
stop();
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
terminal?.dispose?.();
|
|
339
|
-
terminal = undefined;
|
|
579
|
+
teardownTerminal();
|
|
580
|
+
setMode(undefined, undefined);
|
|
340
581
|
}
|
|
341
582
|
|
|
342
583
|
start();
|
|
343
|
-
return { start, stop, dispose, refresh, drill: drillInto };
|
|
584
|
+
return { start, stop, dispose, refresh, drill: drillInto, replay: replayInto };
|
|
344
585
|
}
|
|
345
586
|
|
|
346
587
|
/**
|