@nanobpm/nano-workforce 0.69.0 → 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.
@@ -0,0 +1,161 @@
1
+ // nano-workforce — the transcript READ projection (ADR 0056, H3 / #146, read path #222).
2
+ //
3
+ // The write path (relay.family.ts) flushes an ephemeral agent's PTY stream to a durable transcript on
4
+ // job completion; this module is the READ counterpart the advisory `GET /agentic/transcripts*`
5
+ // endpoints share. It projects a {@link TranscriptStore} row (+ its retained chunks) onto the wire
6
+ // shape and enriches it with the H6 correlation (`app/agentic/correlation.ts`) so a captured session
7
+ // lines up with "that process instance / this plan" — even after the ephemeral agent has exited.
8
+ //
9
+ // Correlation is BEST-EFFORT and advisory: the correlation registry is in-memory and only holds
10
+ // currently-linked jobs, so a completed session's process-instance / plan context is present only
11
+ // while the job is still live. The jobKey itself is always recoverable — it is encoded in the stream
12
+ // id (`job:<jobKey>`), so a past session is never anonymous even once its correlation has been released.
13
+ //
14
+ // Pure and side-effect-free apart from reading the store: no I/O beyond the injected store, so it is
15
+ // unit-testable on the injected env (Node, no browser), and never touches the engine or a BPMN flow.
16
+
17
+ import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
18
+ import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
19
+ import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
20
+
21
+ /** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
22
+ export function byteLengthOf(chunks: readonly TranscriptChunk[]): number {
23
+ let total = 0;
24
+ for (const c of chunks) total += Buffer.byteLength(c.chunk, "utf8");
25
+ return total;
26
+ }
27
+
28
+ /** The correlation fields (jobKey + engine context) a stream id resolves to, best-effort. */
29
+ interface CorrelationFields {
30
+ jobKey?: string;
31
+ processInstanceKey?: string;
32
+ bpmnProcessId?: string;
33
+ elementId?: string;
34
+ planKey?: string;
35
+ }
36
+
37
+ /**
38
+ * Resolve a stream id to its correlation fields: the jobKey is always decoded from a `job:<jobKey>`
39
+ * stream id; the engine context (process instance / plan) is added only when the correlation registry
40
+ * still holds the (live) job. Non-job streams yield an empty object.
41
+ */
42
+ export function correlationFieldsFor(stream: string, correlation: CorrelationRegistry | undefined): CorrelationFields {
43
+ const jobKey = jobKeyOfStream(stream);
44
+ if (jobKey === undefined) return {};
45
+ const fields: CorrelationFields = { jobKey };
46
+ const context = correlation?.resolve(jobKey);
47
+ if (context) {
48
+ if (context.processInstanceKey !== undefined) fields.processInstanceKey = context.processInstanceKey;
49
+ if (context.bpmnProcessId !== undefined) fields.bpmnProcessId = context.bpmnProcessId;
50
+ if (context.elementId !== undefined) fields.elementId = context.elementId;
51
+ if (context.planKey !== undefined) fields.planKey = context.planKey;
52
+ }
53
+ return fields;
54
+ }
55
+
56
+ /** Project a stored transcript's metadata (+ its retained chunks) onto the list wire shape. */
57
+ export function toTranscript(
58
+ meta: TranscriptStream,
59
+ store: TranscriptStore,
60
+ correlation: CorrelationRegistry | undefined,
61
+ ): AgenticTranscript {
62
+ const chunks = store.read(meta.stream);
63
+ const out: AgenticTranscript = {
64
+ stream: meta.stream,
65
+ lifecycle: meta.lifecycle,
66
+ status: meta.status,
67
+ createdAt: meta.createdAt,
68
+ nextOffset: meta.nextOffset,
69
+ byteLength: byteLengthOf(chunks),
70
+ chunkCount: chunks.length,
71
+ };
72
+ if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
73
+ if (meta.firstOffset !== undefined) out.firstOffset = meta.firstOffset;
74
+ const fields = correlationFieldsFor(meta.stream, correlation);
75
+ if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
76
+ if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
77
+ if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
78
+ if (fields.elementId !== undefined) out.elementId = fields.elementId;
79
+ if (fields.planKey !== undefined) out.planKey = fields.planKey;
80
+ return out;
81
+ }
82
+
83
+ /** The filters {@link listTranscripts} understands (all optional; an empty filter returns everything). */
84
+ export interface TranscriptFilter {
85
+ readonly jobKey?: string;
86
+ readonly processInstanceKey?: string;
87
+ readonly planKey?: string;
88
+ /** ISO-8601 lower bound (inclusive) on the session's createdAt. */
89
+ readonly since?: string;
90
+ /** ISO-8601 upper bound (inclusive) on the session's createdAt. */
91
+ readonly until?: string;
92
+ }
93
+
94
+ /**
95
+ * List every captured session projected to the wire shape, sorted newest-first by createdAt (then by
96
+ * stream for a stable tie-break), after applying the (advisory) filters. jobKey / process-instance /
97
+ * plan filters match the correlation-enriched fields; since/until bound createdAt.
98
+ */
99
+ export function listTranscripts(
100
+ store: TranscriptStore,
101
+ correlation: CorrelationRegistry | undefined,
102
+ filter: TranscriptFilter = {},
103
+ ): AgenticTranscript[] {
104
+ const sinceMs = filter.since !== undefined ? Date.parse(filter.since) : undefined;
105
+ const untilMs = filter.until !== undefined ? Date.parse(filter.until) : undefined;
106
+ const rows = store
107
+ .list()
108
+ .map((meta) => toTranscript(meta, store, correlation))
109
+ .filter((t) => {
110
+ if (filter.jobKey !== undefined && t.jobKey !== filter.jobKey) return false;
111
+ if (filter.processInstanceKey !== undefined && t.processInstanceKey !== filter.processInstanceKey) return false;
112
+ if (filter.planKey !== undefined && t.planKey !== filter.planKey) return false;
113
+ const createdMs = Date.parse(t.createdAt);
114
+ if (sinceMs !== undefined && Number.isFinite(createdMs) && createdMs < sinceMs) return false;
115
+ if (untilMs !== undefined && Number.isFinite(createdMs) && createdMs > untilMs) return false;
116
+ return true;
117
+ });
118
+ // Newest session first (a "past sessions" feed reads best most-recent-first); stable on stream id.
119
+ rows.sort((a, b) => {
120
+ const byTime = b.createdAt.localeCompare(a.createdAt);
121
+ return byTime !== 0 ? byTime : a.stream.localeCompare(b.stream);
122
+ });
123
+ return rows;
124
+ }
125
+
126
+ /**
127
+ * Fetch a stored transcript's bytes from offset `from` (inclusive), projected onto the range/offset
128
+ * wire shape — the SAME resume-from-offset contract the live terminal renders, so the cockpit replays
129
+ * a closed stream through its existing renderer. Returns undefined when the stream has no transcript.
130
+ */
131
+ export function readTranscriptFrom(
132
+ stream: string,
133
+ from: number,
134
+ store: TranscriptStore,
135
+ correlation: CorrelationRegistry | undefined,
136
+ ): AgenticTranscriptData | undefined {
137
+ const meta = store.get(stream);
138
+ if (meta === undefined) return undefined;
139
+ const slice = store.since(stream, from);
140
+ const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
141
+ const out: AgenticTranscriptData = {
142
+ stream: meta.stream,
143
+ lifecycle: meta.lifecycle,
144
+ status: meta.status,
145
+ createdAt: meta.createdAt,
146
+ nextOffset: slice.nextOffset,
147
+ byteLength: byteLengthOf(slice.entries),
148
+ chunkCount: entries.length,
149
+ from,
150
+ gap: slice.gap,
151
+ entries,
152
+ };
153
+ if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
154
+ const fields = correlationFieldsFor(meta.stream, correlation);
155
+ if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
156
+ if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
157
+ if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
158
+ if (fields.elementId !== undefined) out.elementId = fields.elementId;
159
+ if (fields.planKey !== undefined) out.planKey = fields.planKey;
160
+ return out;
161
+ }
@@ -36,15 +36,23 @@ test("publicBaseUrl: honours the env override and trims a trailing slash", () =>
36
36
  });
37
37
 
38
38
  test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
39
- const prev = process.env.NANO_PR_BASE_URL;
40
- delete process.env.NANO_PR_BASE_URL;
39
+ // Explicit override args bypass the `process.env.NANO_WORKFORCE_BASE_URL` default, so this test
40
+ // needs no env manipulation — the env-read path is covered by the dedicated test below.
41
+ assertEquals(publicBaseUrl(""), "http://localhost:3000");
42
+ assertEquals(publicBaseUrl(" "), "http://localhost:3000");
43
+ assertEquals(blackboardUrl("t", publicBaseUrl("")), "http://localhost:3000/app/api/hooks/blackboard?token=t");
44
+ });
45
+
46
+ test("publicBaseUrl: reads NANO_WORKFORCE_BASE_URL from the environment by default", () => {
47
+ const prev = process.env.NANO_WORKFORCE_BASE_URL;
41
48
  try {
42
- assertEquals(publicBaseUrl(""), "http://localhost:3000");
43
- assertEquals(publicBaseUrl(" "), "http://localhost:3000");
44
- assertEquals(blackboardUrl("t", publicBaseUrl("")), "http://localhost:3000/app/api/hooks/blackboard?token=t");
49
+ process.env.NANO_WORKFORCE_BASE_URL = "https://fleet.example.com/console/app-view/Workforce/";
50
+ assertEquals(publicBaseUrl(), "https://fleet.example.com/console/app-view/Workforce");
51
+ delete process.env.NANO_WORKFORCE_BASE_URL;
52
+ assertEquals(publicBaseUrl(), "http://localhost:3000");
45
53
  } finally {
46
- if (prev === undefined) delete process.env.NANO_PR_BASE_URL;
47
- else process.env.NANO_PR_BASE_URL = prev;
54
+ if (prev === undefined) delete process.env.NANO_WORKFORCE_BASE_URL;
55
+ else process.env.NANO_WORKFORCE_BASE_URL = prev;
48
56
  }
49
57
  });
50
58
 
package/app/blackboard.ts CHANGED
@@ -70,11 +70,11 @@ export function mintBlackboardToken(): string {
70
70
 
71
71
  /** The externally-reachable base URL agents use to reach this app. Must resolve from WHEREVER the
72
72
  * agent runs (co-located or remote/containerised), so it is configured, never hardcoded. */
73
- export function publicBaseUrl(env: string | undefined = process.env.NANO_PR_PUBLIC_BASE_URL): string {
74
- // Cascade through the fallback chain, skipping any value that is unset OR blank/whitespace, so an
75
- // explicitly-set-but-empty NANO_PR_PUBLIC_BASE_URL can't yield a malformed capability URL.
73
+ export function publicBaseUrl(env: string | undefined = process.env.NANO_WORKFORCE_BASE_URL): string {
74
+ // Skip the override if it is unset OR blank/whitespace, so an explicitly-set-but-empty
75
+ // NANO_WORKFORCE_BASE_URL can't yield a malformed capability URL.
76
76
  const base =
77
- [env, process.env.NANO_PR_BASE_URL, "http://localhost:3000"]
77
+ [env, "http://localhost:3000"]
78
78
  .map((v) => v?.trim())
79
79
  .find((v): v is string => Boolean(v)) ?? "http://localhost:3000";
80
80
  return base.replace(/\/+$/, "");
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
+ });