@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
package/app/blackboard.test.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
assertEquals(publicBaseUrl(
|
|
44
|
-
|
|
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.
|
|
47
|
-
else process.env.
|
|
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.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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,
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|