@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.
- package/AGENTS.md +18 -0
- package/CHANGELOG.md +14 -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/resources/processes/convergence-loop.bpmn +1 -1
- package/resources/processes/feature.bpmn +1 -1
- package/resources/processes/merge-loop.bpmn +2 -2
- package/resources/processes/plan-fanout.bpmn +4 -4
- package/resources/processes/retro.bpmn +1 -1
|
@@ -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
|
+
}
|
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
|
+
});
|