@nanobpm/nano-workforce 0.168.0 → 0.168.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.168.1](https://github.com/nanobpm/nano-workforce/compare/v0.168.0...v0.168.1) (2026-08-31)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **cockpit:** render the agentic transcript beneath the supply table ([#660](https://github.com/nanobpm/nano-workforce/issues/660)) ([#662](https://github.com/nanobpm/nano-workforce/issues/662)) ([ed2c041](https://github.com/nanobpm/nano-workforce/commit/ed2c041e0e4508e825324ed2b398bf64b5c66c37))
6
+
1
7
  ## [0.168.0](https://github.com/nanobpm/nano-workforce/compare/v0.167.4...v0.168.0) (2026-08-31)
2
8
 
3
9
  ### Features
@@ -0,0 +1,102 @@
1
+ // #660 — the browser transcript bundle is DERIVED from the typed core, and it RENDERS (never dumps raw
2
+ // `nwfTranscriptEvent` JSON).
3
+ //
4
+ // Two guarantees:
5
+ // 1. Drift guard — the committed `pages/cockpit/generated/*.js` is byte-identical to a fresh transpile
6
+ // of the `.ts` core, so the deployed browser render path can never silently drift from the typed,
7
+ // tested source (the exact failure mode #660 was: a hand-copy that lost the render path).
8
+ // 2. Behaviour — importing the GENERATED module the browser actually loads and rendering it into the
9
+ // DOM double proves a `nwfTranscriptEvent` chunk is surfaced as derived turns/tool/diff/permission
10
+ // cards, and NEVER verbatim.
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { test } from "node:test";
15
+ import { assert, assertEquals } from "#test-assert";
16
+ import { cockpitBrowserBundle } from "../../../scripts/build-cockpit-browser.ts";
17
+ import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
18
+ import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
19
+ // The module under test is the GENERATED browser artifact the deployed cockpit imports — NOT the .ts
20
+ // source — so this exercises exactly the code path the browser runs.
21
+ import { renderDerivedTranscript } from "../../../pages/cockpit/generated/transcript-derive.js";
22
+
23
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
24
+
25
+ test("the committed browser bundle is byte-identical to its typed source (no drift surface)", () => {
26
+ for (const { out, content } of cockpitBrowserBundle()) {
27
+ const committed = readFileSync(resolve(repoRoot, out), "utf8");
28
+ assertEquals(
29
+ committed,
30
+ content,
31
+ `${out} is stale — regenerate with: node --experimental-strip-types scripts/build-cockpit-browser.ts`,
32
+ );
33
+ }
34
+ });
35
+
36
+ const doc = new FakeDocument();
37
+
38
+ function env(kind: string, extra: Record<string, unknown> = {}): string {
39
+ return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
40
+ }
41
+
42
+ /** A page whose chunks are `nwfTranscriptEvent` envelopes: messages, a tool call/result diff, a permission prompt. */
43
+ function report(): {
44
+ stream: string;
45
+ from: number;
46
+ gap: boolean;
47
+ nextOffset: number;
48
+ entries: Array<{ offset: number; chunk: string }>;
49
+ } {
50
+ const chunks = [
51
+ env("turn", { index: 0 }),
52
+ env("message", { role: "user", text: "please build it" }),
53
+ env("tool-call", { name: "edit", callId: "c1", args: { path: "a.txt", oldText: "one\n", newText: "two\n" } }),
54
+ env("tool-result", { callId: "c1", ok: true, content: "done" }),
55
+ env("message", { role: "assistant", text: "built it" }),
56
+ env("permission", {
57
+ phase: "request",
58
+ callId: "p1",
59
+ policy: "escalate",
60
+ title: "Run shell?",
61
+ toolName: "bash",
62
+ options: [
63
+ { optionId: "ok", name: "Allow", kind: "allow-once" },
64
+ { optionId: "no", name: "Deny", kind: "reject-once" },
65
+ ],
66
+ }),
67
+ ];
68
+ return { stream: "job:1", from: 0, gap: false, nextOffset: chunks.length, entries: chunks.map((chunk, offset) => ({ offset, chunk })) };
69
+ }
70
+
71
+ test("regression: a nwfTranscriptEvent chunk is rendered as derived cards, NEVER surfaced verbatim", () => {
72
+ const host = new FakeElement("div");
73
+ renderDerivedTranscript(host as never, doc, report() as never);
74
+ // The rendered structured view exists…
75
+ assertEquals(host.byClass("cockpit-transcript-derived").length, 1);
76
+ // …and the raw envelope marker is nowhere in the rendered text (the #660 bug: raw JSON echoed).
77
+ assert(!host.text().includes(TRANSCRIPT_EVENT_MARKER), "rendered transcript must not contain the raw nwfTranscriptEvent marker");
78
+ });
79
+
80
+ test("feature: messages coalesce into one turn, tool/diff card renders, permission prompt renders", () => {
81
+ const host = new FakeElement("div");
82
+ renderDerivedTranscript(host as never, doc, report() as never);
83
+
84
+ // Message coalescing: both the user and assistant messages fold under a SINGLE derived turn section.
85
+ const turns = host.byClass("cockpit-transcript-turn");
86
+ assertEquals(turns.length, 1);
87
+ const roles = turns[0]?.byClass("cockpit-transcript-message").map((n) => n.getAttribute("data-role")) ?? [];
88
+ assertEquals(roles, ["user", "assistant"]);
89
+
90
+ // Tool card with a synthesized diff (structured edit args → add/del lines).
91
+ const tool = host.byData("tool", "edit")[0];
92
+ assert(tool !== undefined, "the tool card is rendered");
93
+ assertEquals(tool?.getAttribute("data-tool-kind"), "diff");
94
+ const diffKinds = host.byClass("cockpit-transcript-diff-line").map((n) => n.getAttribute("data-diff-line"));
95
+ assert(diffKinds.includes("add") && diffKinds.includes("del"), "the diff shows add + del lines");
96
+
97
+ // Permission prompt with interactive Allow/Deny options.
98
+ const perm = host.byData("permission", "request")[0];
99
+ assert(perm !== undefined, "the permission prompt is rendered");
100
+ assertEquals(perm?.getAttribute("data-status"), "pending");
101
+ assertEquals(host.byClass("cockpit-transcript-permission-option").length, 2);
102
+ });
@@ -0,0 +1,162 @@
1
+ // #660 — the DEPLOYED browser adapter (pages/cockpit/mount.js) renders the transcript for BOTH a live
2
+ // drill and a past-session replay, and NEVER surfaces a raw `nwfTranscriptEvent` chunk verbatim.
3
+ //
4
+ // This drives mount.js end-to-end on Node against a real (linkedom) DOM, a stub relay WebSocket, and a
5
+ // stub `fetch`, so it exercises the actual live-drill sink wiring and the replay fetch→render path — the
6
+ // two seams that used to write relay chunks straight to xterm. It also asserts the rendered transcript
7
+ // region sits directly beneath the Workers — supply table.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import { encodeFrame } from "@nanobpm/agentic/protocol";
11
+ import { parseHTML } from "linkedom";
12
+ import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
13
+
14
+ function envChunk(kind: string, extra: Record<string, unknown> = {}): string {
15
+ return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
16
+ }
17
+
18
+ /** A stub browser WebSocket that records instances and lets a test drive open + inbound frames by hand. */
19
+ class StubWebSocket {
20
+ static readonly instances: StubWebSocket[] = [];
21
+ binaryType = "";
22
+ readonly url: string;
23
+ readonly #listeners = new Map<string, Array<(event: unknown) => void>>();
24
+ constructor(url: string) {
25
+ this.url = url;
26
+ StubWebSocket.instances.push(this);
27
+ }
28
+ addEventListener(type: string, handler: (event: unknown) => void): void {
29
+ const list = this.#listeners.get(type) ?? [];
30
+ list.push(handler);
31
+ this.#listeners.set(type, list);
32
+ }
33
+ send(): void {}
34
+ close(): void {}
35
+ fireOpen(): void {
36
+ for (const h of this.#listeners.get("open") ?? []) h({});
37
+ }
38
+ /** Deliver one relay frame (as the browser would: an ArrayBuffer message event). */
39
+ deliver(frame: unknown): void {
40
+ const bytes = encodeFrame(frame as never);
41
+ for (const h of this.#listeners.get("message") ?? []) h({ data: bytes.buffer });
42
+ }
43
+ }
44
+
45
+ /** Install a linkedom DOM + stub WebSocket/fetch as globals mount.js reads; returns a cleanup fn. */
46
+ function installEnv(fetchImpl: (url: string) => Promise<unknown>): () => void {
47
+ const { window, document } = parseHTML("<!doctype html><html><body><main id='root'></main></body></html>");
48
+ const g = globalThis as Record<string, unknown>;
49
+ const saved = {
50
+ window: g.window,
51
+ document: g.document,
52
+ location: g.location,
53
+ WebSocket: g.WebSocket,
54
+ fetch: g.fetch,
55
+ };
56
+ g.window = window;
57
+ g.document = document;
58
+ g.location = { hash: "", href: "http://app.test/cockpit/", pathname: "/cockpit/", search: "" };
59
+ g.WebSocket = StubWebSocket;
60
+ g.fetch = (url: unknown) => fetchImpl(String(url));
61
+ StubWebSocket.instances.length = 0;
62
+ return () => {
63
+ g.window = saved.window;
64
+ g.document = saved.document;
65
+ g.location = saved.location;
66
+ g.WebSocket = saved.WebSocket;
67
+ g.fetch = saved.fetch;
68
+ };
69
+ }
70
+
71
+ const SUPPLY = { leaves: [], correlations: [] };
72
+
73
+ /** A fetch stub answering the supply poll, the past-sessions list, and a single-stream replay. */
74
+ function fetchStub(replay?: unknown) {
75
+ return (url: string): Promise<unknown> => {
76
+ const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
77
+ if (url.includes("/supply")) return ok(SUPPLY);
78
+ if (replay !== undefined && /\/transcripts\/[^/]+$/.test(url)) return ok(replay);
79
+ if (url.includes("/transcripts")) return ok({ sessions: [] });
80
+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
81
+ };
82
+ }
83
+
84
+ const OPTS = {
85
+ reportUrl: "http://app.test/app/api/agentic/supply",
86
+ transcriptsUrl: "http://app.test/app/api/agentic/transcripts",
87
+ relayUrl: "ws://app.test/agentic",
88
+ refreshMs: 1_000_000, // effectively disable the self-scheduling poll; we dispose() at the end.
89
+ };
90
+
91
+ test("the rendered transcript region sits directly beneath the Workers — supply table", async () => {
92
+ const restore = installEnv(fetchStub());
93
+ try {
94
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
95
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
96
+ const shell = document.querySelector(".cockpit-shell");
97
+ const order = [...(shell?.children ?? [])].map((c: { className: string }) => c.className);
98
+ assertEquals(order, ["cockpit-supply-region", "cockpit-terminal", "cockpit-past-region"]);
99
+ handle.dispose();
100
+ } finally {
101
+ restore();
102
+ }
103
+ });
104
+
105
+ test("live drill renders the transcript — a nwfTranscriptEvent chunk is never surfaced verbatim", async () => {
106
+ const restore = installEnv(fetchStub());
107
+ try {
108
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
109
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
110
+ handle.drill("job:live");
111
+ const socket = StubWebSocket.instances[0];
112
+ assert(socket !== undefined, "a relay socket was opened for the drill");
113
+ socket.fireOpen();
114
+ socket.deliver({ lane: "control", family: "relay", seq: 0, payload: { op: "subscribed", stream: "job:live", gap: false, nextOffset: 0 } });
115
+ socket.deliver({
116
+ lane: "bulk",
117
+ family: "relay",
118
+ seq: 1,
119
+ payload: { stream: "job:live", offset: 0, chunk: envChunk("message", { role: "assistant", text: "hello from the agent" }) },
120
+ });
121
+
122
+ const host = document.querySelector('[data-terminal="host"]');
123
+ const rendered = host?.querySelector(".cockpit-transcript-derived");
124
+ assert(rendered != null, "the derived transcript is rendered into the terminal host");
125
+ assert((host?.textContent ?? "").includes("hello from the agent"), "the message text is rendered");
126
+ assert(!(host?.textContent ?? "").includes(TRANSCRIPT_EVENT_MARKER), "the raw nwfTranscriptEvent marker is never shown");
127
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
128
+ handle.dispose();
129
+ } finally {
130
+ restore();
131
+ }
132
+ });
133
+
134
+ test("replay renders a past session's transcript — never a raw nwfTranscriptEvent dump", async () => {
135
+ const replay = {
136
+ stream: "job:past",
137
+ from: 0,
138
+ gap: false,
139
+ nextOffset: 3,
140
+ entries: [
141
+ { offset: 0, chunk: envChunk("message", { role: "user", text: "kick off" }) },
142
+ { offset: 1, chunk: envChunk("tool-call", { name: "grep", callId: "c1" }) },
143
+ { offset: 2, chunk: envChunk("tool-result", { callId: "c1", ok: true, content: "match" }) },
144
+ ],
145
+ };
146
+ const restore = installEnv(fetchStub(replay));
147
+ try {
148
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
149
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
150
+ await handle.replay("job:past");
151
+
152
+ const host = document.querySelector('[data-terminal="host"]');
153
+ assert(host?.querySelector(".cockpit-transcript-derived") != null, "the derived transcript is rendered on replay");
154
+ assert((host?.textContent ?? "").includes("kick off"), "the message text is rendered");
155
+ assert(host?.querySelector('[data-tool="grep"]') != null, "the tool card is rendered");
156
+ assert(!(host?.textContent ?? "").includes(TRANSCRIPT_EVENT_MARKER), "the raw nwfTranscriptEvent marker is never shown");
157
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
158
+ handle.dispose();
159
+ } finally {
160
+ restore();
161
+ }
162
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.168.0",
3
+ "version": "0.168.1",
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",
@@ -51,6 +51,8 @@
51
51
  "sync:nav:check": "node --experimental-strip-types scripts/sync-nav.ts --check",
52
52
  "gen:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts",
53
53
  "check:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts --check",
54
+ "gen:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts",
55
+ "check:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts --check",
54
56
  "dev": "urban dev",
55
57
  "pretest": "urban gen",
56
58
  "test": "node --experimental-strip-types --test",
@@ -305,3 +305,121 @@
305
305
  .cockpit-terminal[data-terminal-mode="live"] .cockpit-panel-title {
306
306
  color: var(--cockpit-green);
307
307
  }
308
+
309
+ /* ── Rendered transcript (#660): the derived message turns + tool/diff/permission cards the Worker ──
310
+ terminal shows for both a live drill and a past-session replay, in place of a raw nwfTranscriptEvent
311
+ dump. Mounted into `.cockpit-terminal-host` by renderDerivedTranscript(). */
312
+
313
+ .cockpit-transcript-derived {
314
+ display: grid;
315
+ gap: 12px;
316
+ padding: 8px;
317
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
318
+ max-height: 60vh;
319
+ overflow: auto;
320
+ }
321
+
322
+ .cockpit-transcript-empty {
323
+ color: var(--cockpit-muted);
324
+ font-style: italic;
325
+ padding: 8px;
326
+ }
327
+
328
+ .cockpit-transcript-turn {
329
+ display: grid;
330
+ gap: 8px;
331
+ border-left: 2px solid var(--cockpit-edge);
332
+ padding-left: 10px;
333
+ }
334
+
335
+ .cockpit-transcript-turn-title {
336
+ font-size: 11px;
337
+ margin: 0;
338
+ color: var(--cockpit-muted);
339
+ text-transform: uppercase;
340
+ letter-spacing: 0.04em;
341
+ }
342
+
343
+ .cockpit-transcript-message {
344
+ white-space: pre-wrap;
345
+ overflow-wrap: anywhere;
346
+ padding: 6px 8px;
347
+ border-radius: 6px;
348
+ background: rgba(34, 48, 65, 0.35);
349
+ }
350
+
351
+ .cockpit-transcript-message[data-role="user"] { border-left: 3px solid #58a6ff; }
352
+ .cockpit-transcript-message[data-role="assistant"] { border-left: 3px solid var(--cockpit-green); }
353
+ .cockpit-transcript-message[data-role="system"],
354
+ .cockpit-transcript-message[data-role="tool"] { border-left: 3px solid var(--cockpit-muted); color: var(--cockpit-muted); }
355
+
356
+ .cockpit-transcript-tool {
357
+ border: 1px solid var(--cockpit-edge);
358
+ border-radius: 6px;
359
+ padding: 8px;
360
+ background: rgba(19, 26, 34, 0.6);
361
+ }
362
+
363
+ .cockpit-transcript-tool-name {
364
+ font-weight: 600;
365
+ color: #b392f0;
366
+ }
367
+
368
+ .cockpit-transcript-tool[data-status="error"] .cockpit-transcript-tool-name { color: var(--cockpit-red); }
369
+ .cockpit-transcript-tool[data-status="ok"] .cockpit-transcript-tool-name::after { content: " ✓"; color: var(--cockpit-green); }
370
+
371
+ .cockpit-transcript-tool-args,
372
+ .cockpit-transcript-tool-result,
373
+ .cockpit-transcript-diff {
374
+ margin: 6px 0 0;
375
+ white-space: pre-wrap;
376
+ overflow-wrap: anywhere;
377
+ background: #05080b;
378
+ border-radius: 4px;
379
+ padding: 6px 8px;
380
+ }
381
+
382
+ .cockpit-transcript-diff-line[data-diff-line="add"] { color: var(--cockpit-green); }
383
+ .cockpit-transcript-diff-line[data-diff-line="del"] { color: var(--cockpit-red); }
384
+ .cockpit-transcript-diff-line[data-diff-line="ctx"] { color: var(--cockpit-muted); }
385
+
386
+ .cockpit-transcript-permission {
387
+ border: 1px solid var(--cockpit-amber);
388
+ border-radius: 6px;
389
+ padding: 8px;
390
+ background: rgba(210, 153, 34, 0.08);
391
+ }
392
+
393
+ .cockpit-transcript-permission-title { font-weight: 600; }
394
+ .cockpit-transcript-permission-reason,
395
+ .cockpit-transcript-permission-note { color: var(--cockpit-muted); font-size: 12px; }
396
+
397
+ .cockpit-transcript-permission[data-status="allowed"] { border-color: var(--cockpit-green); }
398
+ .cockpit-transcript-permission[data-status="denied"] { border-color: var(--cockpit-red); }
399
+
400
+ .cockpit-transcript-permission-actions {
401
+ display: flex;
402
+ flex-wrap: wrap;
403
+ gap: 8px;
404
+ margin-top: 8px;
405
+ }
406
+
407
+ .cockpit-transcript-permission-option {
408
+ cursor: pointer;
409
+ font: inherit;
410
+ color: var(--cockpit-text);
411
+ background: var(--cockpit-panel);
412
+ border: 1px solid var(--cockpit-edge);
413
+ border-radius: 6px;
414
+ padding: 4px 10px;
415
+ }
416
+
417
+ .cockpit-transcript-permission-option[data-allowed="true"]:hover { border-color: var(--cockpit-green); }
418
+ .cockpit-transcript-permission-option[data-allowed="false"]:hover { border-color: var(--cockpit-red); }
419
+
420
+ .cockpit-transcript-raw {
421
+ color: var(--cockpit-muted);
422
+ font-size: 11px;
423
+ border-top: 1px solid var(--cockpit-edge);
424
+ padding-top: 6px;
425
+ }
@@ -5,15 +5,13 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Agent cockpit — supply (App View embed)</title>
7
7
  <link rel="stylesheet" href="./cockpit.css" />
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css" />
9
8
  <style>
10
9
  html, body { margin: 0; height: 100%; background: #0b0f14; }
11
10
  </style>
12
11
  <script type="importmap">
13
12
  {
14
13
  "imports": {
15
- "@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js",
16
- "@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/+esm"
14
+ "@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js"
17
15
  }
18
16
  }
19
17
  </script>