@nanobpm/nano-workforce 0.69.1 → 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/AGENTS.md +18 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +10 -8
  4. package/app/agentic/cockpit/index.ts +18 -0
  5. package/app/agentic/cockpit/supply-boot-past.test.ts +519 -0
  6. package/app/agentic/cockpit/supply-boot.test.ts +34 -0
  7. package/app/agentic/cockpit/supply-boot.ts +256 -21
  8. package/app/agentic/cockpit/transcript-render.test.ts +110 -0
  9. package/app/agentic/cockpit/transcript-render.ts +136 -0
  10. package/app/agentic/cockpit/transcript-view.test.ts +61 -0
  11. package/app/agentic/cockpit/transcript-view.ts +131 -0
  12. package/app/agentic/families/relay.family.test.ts +103 -0
  13. package/app/agentic/families/relay.family.ts +74 -0
  14. package/app/agentic/transcript-read.test.ts +72 -0
  15. package/app/agentic/transcript-read.ts +161 -0
  16. package/app/blackboard.test.ts +15 -7
  17. package/app/blackboard.ts +4 -4
  18. package/app/convergeGate.test.ts +406 -0
  19. package/app/convergeGate.ts +48 -0
  20. package/app/github.ts +225 -0
  21. package/app/roundProgress.test.ts +229 -0
  22. package/app/roundProgress.ts +70 -0
  23. package/app/service.test.ts +18 -1
  24. package/app/service.ts +5 -1
  25. package/db/migrations/033_pr_round_head.sql +16 -0
  26. package/nano.app.json +8 -0
  27. package/openapi.yaml +267 -0
  28. package/operations/getAgenticTranscript.test.ts +165 -0
  29. package/operations/getAgenticTranscript.ts +42 -0
  30. package/operations/listAgenticTranscripts.test.ts +169 -0
  31. package/operations/listAgenticTranscripts.ts +61 -0
  32. package/package.json +1 -1
  33. package/pages/cockpit/cockpit.css +70 -0
  34. package/pages/cockpit/mount.js +254 -13
  35. package/pages/cockpit.page.json +1 -1
  36. package/prompts/review-round.md +55 -9
  37. package/resources/processes/convergence-loop.bpmn +236 -65
  38. package/workers/converge-gate/worker.ts +101 -0
  39. package/workers/progress-check/worker.ts +77 -0
package/openapi.yaml CHANGED
@@ -204,6 +204,167 @@ components:
204
204
  processed, so the cockpit can line each worker's terminal up with its process instance / plan (H6).
205
205
  items:
206
206
  $ref: "#/components/schemas/AgenticJobCorrelation"
207
+ AgenticTranscript:
208
+ type: object
209
+ description: One captured agent session's transcript metadata (H3/#146 transcript store). A durable
210
+ record of an ephemeral agent's terminal stream, readable AFTER the agent has exited. jobKey and
211
+ the process-instance / plan correlation are derived from the stream id (`job:<jobKey>`) and, when
212
+ the H6 correlation registry still holds the (live) job, its engine context (advisory, best-effort).
213
+ required:
214
+ - stream
215
+ - lifecycle
216
+ - status
217
+ - createdAt
218
+ - nextOffset
219
+ - byteLength
220
+ - chunkCount
221
+ properties:
222
+ stream:
223
+ type: string
224
+ description: The relay stream id the transcript was captured under (`job:<jobKey>` for a job stream).
225
+ lifecycle:
226
+ type: string
227
+ enum: [ephemeral, long-lived]
228
+ description: Retention lifecycle — ephemeral (flushed once on job completion, swept after retention)
229
+ or long-lived (checkpointed, bounded by a rolling offset window).
230
+ status:
231
+ type: string
232
+ enum: [open, completed]
233
+ description: open (still capturing / reattachable) or completed (the ephemeral run flushed & sealed).
234
+ createdAt:
235
+ type: string
236
+ description: When the stream was first opened, ISO-8601.
237
+ completedAt:
238
+ type: string
239
+ description: When an ephemeral run was flushed & completed, ISO-8601 (absent while open).
240
+ firstOffset:
241
+ type: integer
242
+ description: The oldest retained chunk offset, or absent when the transcript holds no chunks.
243
+ nextOffset:
244
+ type: integer
245
+ description: One past the highest offset ever recorded (the resume high-water mark).
246
+ byteLength:
247
+ type: integer
248
+ description: Total captured bytes across every retained chunk.
249
+ chunkCount:
250
+ type: integer
251
+ description: The number of retained chunks.
252
+ jobKey:
253
+ type: string
254
+ description: The Camunda-8 job key, decoded from a `job:<jobKey>` stream id (absent for other streams).
255
+ processInstanceKey:
256
+ type: string
257
+ description: The owning process instance key, when the correlation is still known (advisory).
258
+ bpmnProcessId:
259
+ type: string
260
+ description: The BPMN process id the job belonged to, when still known (advisory).
261
+ elementId:
262
+ type: string
263
+ description: The BPMN element id (activity/task) the job was for, when still known (advisory).
264
+ planKey:
265
+ type: string
266
+ description: The plan / epic key this job was part of (e.g. owner/repo#142), when still known (advisory).
267
+ AgenticTranscriptList:
268
+ type: object
269
+ description: The list of captured agent sessions (past + open) — the cockpit "past sessions" feed.
270
+ required:
271
+ - count
272
+ - transcripts
273
+ properties:
274
+ count:
275
+ type: integer
276
+ description: The number of transcripts returned (after any filters).
277
+ generatedAt:
278
+ type: string
279
+ description: When this snapshot was taken, ISO-8601.
280
+ retentionMs:
281
+ type: integer
282
+ description: The completed-ephemeral retention window in ms (how long a finished session is kept
283
+ before a retention sweep may drop it). Absent when no transcript store is mounted.
284
+ transcripts:
285
+ type: array
286
+ items:
287
+ $ref: "#/components/schemas/AgenticTranscript"
288
+ AgenticTranscriptChunk:
289
+ type: object
290
+ description: One durable transcript chunk and the offset it was recorded at.
291
+ required:
292
+ - offset
293
+ - chunk
294
+ properties:
295
+ offset:
296
+ type: integer
297
+ description: The chunk's offset (resume key) — the terminal renderer resumes-from-offset off it.
298
+ chunk:
299
+ type: string
300
+ description: The captured terminal bytes for this offset.
301
+ AgenticTranscriptData:
302
+ type: object
303
+ description: A stored transcript's bytes, range/offset-based so the cockpit terminal replays it through
304
+ the SAME resume-from-offset renderer it uses for a live stream (static playback of a closed stream).
305
+ required:
306
+ - stream
307
+ - lifecycle
308
+ - status
309
+ - createdAt
310
+ - nextOffset
311
+ - byteLength
312
+ - chunkCount
313
+ - from
314
+ - gap
315
+ - entries
316
+ properties:
317
+ stream:
318
+ type: string
319
+ description: The relay stream id.
320
+ lifecycle:
321
+ type: string
322
+ enum: [ephemeral, long-lived]
323
+ status:
324
+ type: string
325
+ enum: [open, completed]
326
+ createdAt:
327
+ type: string
328
+ description: When the stream was first opened, ISO-8601.
329
+ completedAt:
330
+ type: string
331
+ description: When an ephemeral run was flushed & completed, ISO-8601 (absent while open).
332
+ nextOffset:
333
+ type: integer
334
+ description: One past the highest recorded offset (where a live stream would continue).
335
+ byteLength:
336
+ type: integer
337
+ description: Total captured bytes across the returned chunks.
338
+ chunkCount:
339
+ type: integer
340
+ description: The number of returned chunks.
341
+ from:
342
+ type: integer
343
+ description: The requested resume offset (inclusive) this page starts at.
344
+ gap:
345
+ type: boolean
346
+ description: True when `from` predates the oldest retained offset — earlier chunks were dropped by
347
+ retention, so the replay is a best-effort resume, not gap-free from `from`.
348
+ jobKey:
349
+ type: string
350
+ description: The Camunda-8 job key, decoded from a `job:<jobKey>` stream id (absent otherwise).
351
+ processInstanceKey:
352
+ type: string
353
+ description: The owning process instance key, when still known (advisory).
354
+ bpmnProcessId:
355
+ type: string
356
+ description: The BPMN process id, when still known (advisory).
357
+ elementId:
358
+ type: string
359
+ description: The BPMN element id, when still known (advisory).
360
+ planKey:
361
+ type: string
362
+ description: The plan / epic key, when still known (advisory).
363
+ entries:
364
+ type: array
365
+ description: The retained chunks with `offset >= from`, in offset order.
366
+ items:
367
+ $ref: "#/components/schemas/AgenticTranscriptChunk"
207
368
  VersionInfo:
208
369
  type: object
209
370
  description: The running app's identity (which code is actually live).
@@ -759,6 +920,112 @@ paths:
759
920
  application/json:
760
921
  schema:
761
922
  $ref: "#/components/schemas/ErrorBody"
923
+ /agentic/transcripts:
924
+ get:
925
+ operationId: listAgenticTranscripts
926
+ summary: List captured agent sessions (H3/#146) — the durable transcripts an ephemeral agent flushed
927
+ on job completion, readable AFTER it exited. Optional filters by jobKey / process instance / plan /
928
+ time. Advisory read-only; never gates control flow. Feeds the cockpit "past sessions" view.
929
+ security:
930
+ - hookSecret: []
931
+ - {}
932
+ parameters:
933
+ - name: jobKey
934
+ in: query
935
+ required: false
936
+ schema:
937
+ type: string
938
+ description: Return only the transcript for this Camunda-8 job key (its `job:<jobKey>` stream).
939
+ - name: processInstanceKey
940
+ in: query
941
+ required: false
942
+ schema:
943
+ type: string
944
+ description: Return only transcripts whose (still-known) correlation names this process instance.
945
+ - name: planKey
946
+ in: query
947
+ required: false
948
+ schema:
949
+ type: string
950
+ description: Return only transcripts whose (still-known) correlation names this plan / epic key.
951
+ - name: since
952
+ in: query
953
+ required: false
954
+ schema:
955
+ type: string
956
+ description: Return only sessions created at or after this ISO-8601 instant.
957
+ - name: until
958
+ in: query
959
+ required: false
960
+ schema:
961
+ type: string
962
+ description: Return only sessions created at or before this ISO-8601 instant.
963
+ responses:
964
+ "200":
965
+ description: The captured session list.
966
+ content:
967
+ application/json:
968
+ schema:
969
+ $ref: "#/components/schemas/AgenticTranscriptList"
970
+ "400":
971
+ description: A malformed filter (e.g. an unparseable since/until).
972
+ content:
973
+ application/json:
974
+ schema:
975
+ $ref: "#/components/schemas/ErrorBody"
976
+ "401":
977
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
978
+ content:
979
+ application/json:
980
+ schema:
981
+ $ref: "#/components/schemas/ErrorBody"
982
+ /agentic/transcripts/{stream}:
983
+ get:
984
+ operationId: getAgenticTranscript
985
+ summary: Fetch a stored transcript's bytes (H3/#146), range/offset-based so the cockpit terminal
986
+ replays it through the same resume-from-offset renderer it uses for a live stream. Advisory
987
+ read-only; never gates control flow.
988
+ security:
989
+ - hookSecret: []
990
+ - {}
991
+ parameters:
992
+ - name: stream
993
+ in: path
994
+ required: true
995
+ schema:
996
+ type: string
997
+ description: The relay stream id to fetch (`job:<jobKey>` for a job stream).
998
+ - name: from
999
+ in: query
1000
+ required: false
1001
+ schema:
1002
+ type: integer
1003
+ description: Resume from this offset (inclusive). Default 0 (the whole retained transcript).
1004
+ responses:
1005
+ "200":
1006
+ description: The stored transcript bytes from `from`.
1007
+ content:
1008
+ application/json:
1009
+ schema:
1010
+ $ref: "#/components/schemas/AgenticTranscriptData"
1011
+ "400":
1012
+ description: A malformed `from` offset.
1013
+ content:
1014
+ application/json:
1015
+ schema:
1016
+ $ref: "#/components/schemas/ErrorBody"
1017
+ "401":
1018
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
1019
+ content:
1020
+ application/json:
1021
+ schema:
1022
+ $ref: "#/components/schemas/ErrorBody"
1023
+ "404":
1024
+ description: No transcript exists for the given stream.
1025
+ content:
1026
+ application/json:
1027
+ schema:
1028
+ $ref: "#/components/schemas/ErrorBody"
762
1029
  /version:
763
1030
  get:
764
1031
  operationId: getVersion
@@ -0,0 +1,165 @@
1
+ // Tests for GET /app/api/agentic/transcripts/{stream} → operation `getAgenticTranscript` (H3, #222).
2
+ //
3
+ // Covers: 404 when no store / unknown stream; the range/offset fetch (from=0 whole transcript, from>0
4
+ // resume-from-offset with the mirrored gap flag); the shared-secret guard; and correlation enrichment.
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { test } from "node:test";
7
+ import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
8
+ import type { Frame } from "@nanobpm/agentic/protocol";
9
+ import type { SqliteDb } from "@nanobpm/agentic/transcript";
10
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import { createRelayFamily, currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
13
+ import type { AgenticContext } from "../app/agentic/registry.ts";
14
+ import { noopLog } from "../test/log.ts";
15
+ import handler from "./getAgenticTranscript.ts";
16
+
17
+ function memSqlite(): SqliteDb {
18
+ const db = new DatabaseSync(":memory:");
19
+ return {
20
+ exec: (sql) => db.exec(sql),
21
+ run: (sql, params = []) => {
22
+ const r = db.prepare(sql).run(...(params as never[]));
23
+ return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
24
+ },
25
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
26
+ db.prepare(sql).all(...(params as never[])) as T[],
27
+ };
28
+ }
29
+
30
+ function memData(db: SqliteDb): DataLayer {
31
+ return { source: () => ({ db }) } as unknown as DataLayer;
32
+ }
33
+
34
+ function fakeHub() {
35
+ return {
36
+ registerFamilyHandler(_family: string, _handler: (frame: Frame, conn: never) => void) {},
37
+ registry: { has: () => false, list: () => [] },
38
+ };
39
+ }
40
+
41
+ function mountCtx(db: SqliteDb): AgenticContext {
42
+ const hub = fakeHub();
43
+ return {
44
+ hub: hub as never,
45
+ registry: hub.registry as unknown as ConnectionRegistry,
46
+ transport: undefined as never,
47
+ data: memData(db),
48
+ log: noopLog(),
49
+ };
50
+ }
51
+
52
+ function input(stream: string, query: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
53
+ return {
54
+ req: { method: "GET", path: `/app/api/agentic/transcripts/${stream}`, query: new URLSearchParams(), headers: new Headers(headers), text: async () => "" } as never,
55
+ params: { stream },
56
+ query,
57
+ body: undefined,
58
+ };
59
+ }
60
+
61
+ const app = { log: noopLog() } as unknown as AppApi;
62
+ const relayFamily = createRelayFamily();
63
+
64
+ test("404 when no store is mounted", async () => {
65
+ relayFamily.teardown?.();
66
+ const res = (await handler(input("job:1"), app)) as { status: number };
67
+ assertEquals(res.status, 404);
68
+ });
69
+
70
+ test("404 for an unknown stream", async () => {
71
+ relayFamily.mount(mountCtx(memSqlite()));
72
+ try {
73
+ const res = (await handler(input("job:nope"), app)) as { status: number };
74
+ assertEquals(res.status, 404);
75
+ } finally {
76
+ relayFamily.teardown?.();
77
+ }
78
+ });
79
+
80
+ test("returns the whole transcript from offset 0, then a resume slice from a later offset", async () => {
81
+ relayFamily.mount(mountCtx(memSqlite()));
82
+ const store = currentRelayTranscriptService()?.store;
83
+ assert(store !== undefined);
84
+ store.flush(
85
+ "job:6494",
86
+ { since: () => ({ entries: [{ offset: 0, chunk: "aa" }, { offset: 1, chunk: "bb" }, { offset: 2, chunk: "cc" }] }), nextOffset: 3 },
87
+ "ephemeral",
88
+ );
89
+ try {
90
+ const whole = (await handler(input("job:6494"), app)) as {
91
+ status: number;
92
+ body: { stream: string; from: number; gap: boolean; nextOffset: number; chunkCount: number; byteLength: number; entries: Array<{ offset: number; chunk: string }>; jobKey?: string };
93
+ };
94
+ assertEquals(whole.status, 200);
95
+ assertEquals(whole.body.stream, "job:6494");
96
+ assertEquals(whole.body.jobKey, "6494");
97
+ assertEquals(whole.body.from, 0);
98
+ assertEquals(whole.body.gap, false);
99
+ assertEquals(whole.body.nextOffset, 3);
100
+ assertEquals(whole.body.chunkCount, 3);
101
+ assertEquals(whole.body.byteLength, 6);
102
+ assertEquals(whole.body.entries.map((e) => e.offset), [0, 1, 2]);
103
+
104
+ const resume = (await handler(input("job:6494", { from: 2 }), app)) as {
105
+ body: { from: number; chunkCount: number; entries: Array<{ offset: number; chunk: string }> };
106
+ };
107
+ assertEquals(resume.body.from, 2);
108
+ assertEquals(resume.body.chunkCount, 1);
109
+ assertEquals(resume.body.entries.map((e) => e.chunk), ["cc"]);
110
+ } finally {
111
+ relayFamily.teardown?.();
112
+ }
113
+ });
114
+
115
+ test("rejects a malformed from offset with a 400", async () => {
116
+ relayFamily.mount(mountCtx(memSqlite()));
117
+ try {
118
+ const res = (await handler(input("job:1", { from: -1 }), app)) as { status: number };
119
+ assertEquals(res.status, 400);
120
+ } finally {
121
+ relayFamily.teardown?.();
122
+ }
123
+ });
124
+
125
+ test("shared-secret guard rejects a missing secret when configured", async () => {
126
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
127
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
128
+ try {
129
+ const mod = await import(`./getAgenticTranscript.ts?guard=${Date.now()}`);
130
+ const guarded = mod.default as typeof handler;
131
+ const bad = (await guarded(input("job:1"), app)) as { status: number };
132
+ assertEquals(bad.status, 401);
133
+ } finally {
134
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
135
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
136
+ }
137
+ });
138
+
139
+ test("shared-secret guard admits a correct secret when configured", async () => {
140
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
141
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
142
+ relayFamily.mount(mountCtx(memSqlite()));
143
+ const store = currentRelayTranscriptService()?.store;
144
+ assert(store !== undefined);
145
+ store.flush(
146
+ "job:6494",
147
+ { since: () => ({ entries: [{ offset: 0, chunk: "aa" }] }), nextOffset: 1 },
148
+ "ephemeral",
149
+ );
150
+ try {
151
+ const mod = await import(`./getAgenticTranscript.ts?guard=${Date.now()}`);
152
+ const guarded = mod.default as typeof handler;
153
+ const ok = (await guarded(input("job:6494", {}, { "x-hook-secret": "s3cr3t" }), app)) as {
154
+ status: number;
155
+ body: { stream: string; chunkCount: number };
156
+ };
157
+ assertEquals(ok.status, 200);
158
+ assertEquals(ok.body.stream, "job:6494");
159
+ assertEquals(ok.body.chunkCount, 1);
160
+ } finally {
161
+ relayFamily.teardown?.();
162
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
163
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
164
+ }
165
+ });
@@ -0,0 +1,42 @@
1
+ // GET /app/api/agentic/transcripts/{stream} → operationId `getAgenticTranscript` (ADR 0056, H3 #222).
2
+ //
3
+ // Fetch a stored transcript's bytes, range/offset-based (?from=<offset>, default 0) so the cockpit
4
+ // terminal replays a closed stream through the SAME resume-from-offset renderer it uses for a live one
5
+ // (static playback of an exited agent). Sourced from the mounted relay/transcript service's
6
+ // TranscriptStore over `app.data`, correlated best-effort via `app/agentic/correlation.ts`.
7
+ //
8
+ // Advisory read-only (ADR 0056): it NEVER gates a BPMN sequence flow. Unknown stream -> 404; a
9
+ // malformed `from` -> 400. Shared-secret guard mirrors getAgenticSupply (x-hook-secret when
10
+ // NANO_PR_WEBHOOK_SECRET is set; unset -> open).
11
+
12
+ import { currentCorrelation } from "../app/agentic/correlation.ts";
13
+ import { currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
14
+ import { readTranscriptFrom } from "../app/agentic/transcript-read.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("getAgenticTranscript", async ({ params, query, req }, app) => {
21
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
22
+ app.log.warn("getAgenticTranscript rejected: missing/invalid shared secret");
23
+ return { status: 401, body: { error: "unauthorized" } };
24
+ }
25
+
26
+ const from = query.from ?? 0;
27
+ if (!Number.isSafeInteger(from) || from < 0) {
28
+ return { status: 400, body: { error: "invalid from: expected a non-negative integer offset" } };
29
+ }
30
+
31
+ const store = currentRelayTranscriptService()?.store;
32
+ if (!store) {
33
+ // No transcript store mounted (relay unmounted or unpersisted) - nothing to replay.
34
+ return { status: 404, body: { error: "no transcript for stream" } };
35
+ }
36
+
37
+ const data = readTranscriptFrom(params.stream, from, store, currentCorrelation());
38
+ if (data === undefined) {
39
+ return { status: 404, body: { error: "no transcript for stream" } };
40
+ }
41
+ return { status: 200, body: data };
42
+ });
@@ -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
+ });