@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,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
+ });
@@ -0,0 +1,61 @@
1
+ // GET /app/api/agentic/transcripts → operationId `listAgenticTranscripts` (ADR 0056, H3 read path #222).
2
+ //
3
+ // The READ counterpart to the write-only transcript store (H3 #146): it lists the durable transcripts an
4
+ // ephemeral agent flushed on job completion, so an operator can review "what did that agent do" AFTER it
5
+ // is gone. Sourced from the mounted relay/transcript service's TranscriptStore (over `app.data`) and
6
+ // correlated via `app/agentic/correlation.ts` (best-effort — jobKey is always recovered from the stream
7
+ // id, engine context only while the job is still live). Feeds the cockpit "past sessions" view.
8
+ //
9
+ // Advisory read-only (ADR 0056): it NEVER gates a BPMN sequence flow. Optional filters (jobKey / process
10
+ // instance / plan / time) narrow the feed. The optional shared-secret guard mirrors getAgenticSupply:
11
+ // when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header; unset -> open.
12
+
13
+ import { currentCorrelation } from "../app/agentic/correlation.ts";
14
+ import { currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
15
+ import { listTranscripts, type TranscriptFilter } from "../app/agentic/transcript-read.ts";
16
+ import { envVar } from "../app/version.ts";
17
+ import type { AgenticTranscriptList } from "../nano-generated/api-io.d.ts";
18
+ import { defineOperation } from "../nano-generated/operations.ts";
19
+
20
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
21
+
22
+ /** Reject an ISO-8601 filter that does not parse (a malformed since/until is a 400, not a silent no-op). */
23
+ function badInstant(value: string | undefined): boolean {
24
+ return value !== undefined && !Number.isFinite(Date.parse(value));
25
+ }
26
+
27
+ export default defineOperation("listAgenticTranscripts", async ({ query, req }, app) => {
28
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
29
+ app.log.warn("listAgenticTranscripts rejected: missing/invalid shared secret");
30
+ return { status: 401, body: { error: "unauthorized" } };
31
+ }
32
+
33
+ if (badInstant(query.since) || badInstant(query.until)) {
34
+ return { status: 400, body: { error: "invalid since/until: expected an ISO-8601 instant" } };
35
+ }
36
+
37
+ const service = currentRelayTranscriptService();
38
+ const store = service?.store;
39
+ if (!store) {
40
+ // The relay family has not mounted, or is running unpersisted (no DataLayer) - no transcripts to
41
+ // report, not an error (advisory).
42
+ const empty: AgenticTranscriptList = { count: 0, generatedAt: new Date().toISOString(), transcripts: [] };
43
+ return { status: 200, body: empty };
44
+ }
45
+
46
+ const filter: TranscriptFilter = {
47
+ ...(query.jobKey !== undefined ? { jobKey: query.jobKey } : {}),
48
+ ...(query.processInstanceKey !== undefined ? { processInstanceKey: query.processInstanceKey } : {}),
49
+ ...(query.planKey !== undefined ? { planKey: query.planKey } : {}),
50
+ ...(query.since !== undefined ? { since: query.since } : {}),
51
+ ...(query.until !== undefined ? { until: query.until } : {}),
52
+ };
53
+ const transcripts = listTranscripts(store, currentCorrelation(), filter);
54
+ const body: AgenticTranscriptList = {
55
+ count: transcripts.length,
56
+ generatedAt: new Date().toISOString(),
57
+ retentionMs: store.ephemeralRetentionMs,
58
+ transcripts,
59
+ };
60
+ return { status: 200, body };
61
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -42,6 +42,7 @@
42
42
  }
43
43
 
44
44
  .cockpit-supply-region,
45
+ .cockpit-past-region,
45
46
  .cockpit-terminal {
46
47
  background: var(--cockpit-panel);
47
48
  border: 1px solid var(--cockpit-edge);
@@ -160,3 +161,72 @@
160
161
  background: #05080b;
161
162
  border-radius: 6px;
162
163
  }
164
+
165
+ /* ── Past sessions (H3 read path / #222): the captured-session history + replay. ──────────────── */
166
+
167
+ .cockpit-past-header {
168
+ display: flex;
169
+ flex-wrap: wrap;
170
+ align-items: baseline;
171
+ justify-content: space-between;
172
+ gap: 8px;
173
+ margin-bottom: 8px;
174
+ }
175
+
176
+ .cockpit-past-title {
177
+ font-size: 13px;
178
+ margin: 0;
179
+ color: var(--cockpit-muted);
180
+ text-transform: uppercase;
181
+ letter-spacing: 0.04em;
182
+ }
183
+
184
+ .cockpit-past-summary {
185
+ color: var(--cockpit-muted);
186
+ font-size: 12px;
187
+ font-variant-numeric: tabular-nums;
188
+ }
189
+
190
+ .cockpit-past-table {
191
+ width: 100%;
192
+ border-collapse: collapse;
193
+ font-variant-numeric: tabular-nums;
194
+ }
195
+
196
+ .cockpit-past-replay {
197
+ background: none;
198
+ border: none;
199
+ color: var(--cockpit-text);
200
+ cursor: pointer;
201
+ font: inherit;
202
+ padding: 0;
203
+ text-align: left;
204
+ text-decoration: underline;
205
+ text-underline-offset: 2px;
206
+ }
207
+
208
+ .cockpit-past-replay:hover { color: #58a6ff; }
209
+
210
+ .cockpit-past-session[data-active="true"] {
211
+ background: rgba(88, 166, 255, 0.12);
212
+ }
213
+
214
+ .cockpit-past-status { color: var(--cockpit-muted); }
215
+ .cockpit-past-size { color: var(--cockpit-muted); }
216
+ .cockpit-past-captured { color: var(--cockpit-muted); font-size: 12px; }
217
+
218
+ .cockpit-past-empty {
219
+ color: var(--cockpit-muted);
220
+ padding: 8px 0;
221
+ }
222
+
223
+ /* Distinguish a live terminal from a replayed (static) past session at a glance. */
224
+ .cockpit-terminal[data-terminal-mode="replay"] {
225
+ border-color: #8957e5;
226
+ }
227
+ .cockpit-terminal[data-terminal-mode="replay"] .cockpit-panel-title {
228
+ color: #b392f0;
229
+ }
230
+ .cockpit-terminal[data-terminal-mode="live"] .cockpit-panel-title {
231
+ color: var(--cockpit-green);
232
+ }