@nanobpm/nano-workforce 0.168.0 → 0.168.2
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 +12 -0
- package/app/agentic/cockpit/cockpit-browser-bundle.test.ts +102 -0
- package/app/agentic/cockpit/mount.test.ts +162 -0
- package/package.json +4 -2
- package/pages/cockpit/cockpit.css +118 -0
- package/pages/cockpit/embed.html +1 -3
- package/pages/cockpit/generated/transcript-derive.js +260 -0
- package/pages/cockpit/generated/transcript-events.js +488 -0
- package/pages/cockpit/mount.js +85 -39
- package/pages/cockpit/standalone.html +7 -8
- package/scripts/build-cockpit-browser.ts +110 -0
package/pages/cockpit/mount.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// This is the ONE wiring both the standalone shell and the console App-View embed call — they differ
|
|
4
4
|
// only in the host element they pass, so the supply cockpit renders identically embedded and
|
|
5
5
|
// standalone. It supplies the browser capabilities the injection-based core needs: the real
|
|
6
|
-
// `document`, a `fetch`-based supply report source, a `WebSocket` relay socket factory, and
|
|
7
|
-
//
|
|
6
|
+
// `document`, a `fetch`-based supply report source, a `WebSocket` relay socket factory, and a
|
|
7
|
+
// rendered-transcript sink (message turns + tool/diff/permission cards) over the relay stream.
|
|
8
8
|
//
|
|
9
9
|
// The genuinely reusable, correctness-critical parts — the relay client and the resume-from-offset
|
|
10
10
|
// terminal session — are REUSED from `@nanobpm/agentic/cockpit` (resolved via the host page's import
|
|
@@ -16,8 +16,16 @@
|
|
|
16
16
|
//
|
|
17
17
|
// It renders the SUPPLY worker list ONLY — NOT the packaged demand×supply matrix / missing-agent reds
|
|
18
18
|
// / diversity-SLO light (deferred to enrolment epic #152).
|
|
19
|
+
//
|
|
20
|
+
// The transcript is RENDERED, not dumped (#660): both the live drill and the past-session replay feed
|
|
21
|
+
// their `nwfTranscriptEvent` chunks through `renderDerivedTranscript` — the SAME parse→derive→render
|
|
22
|
+
// path the typed core uses — so the operator sees readable message turns + tool/diff/permission cards
|
|
23
|
+
// instead of raw event JSON. That renderer is NOT re-copied here (the drift that caused #660): it is
|
|
24
|
+
// imported from `./generated/transcript-derive.js`, a build-step emit of the ONE typed core
|
|
25
|
+
// (`app/agentic/cockpit/transcript-derive.ts` + `app/agentic/transcript-events.ts`) that a drift-guard
|
|
26
|
+
// test keeps byte-identical to its source.
|
|
19
27
|
import { RelayChannelClient, TerminalSession } from "@nanobpm/agentic/cockpit";
|
|
20
|
-
import {
|
|
28
|
+
import { renderDerivedTranscript } from "./generated/transcript-derive.js";
|
|
21
29
|
|
|
22
30
|
const DEFAULT_REFRESH_MS = 2000;
|
|
23
31
|
const DEFAULT_STALE_AFTER_MS = 15_000;
|
|
@@ -382,23 +390,48 @@ function renderTranscripts(host, doc, view, onReplay, activeStream, title = "Pas
|
|
|
382
390
|
host.appendChild(root);
|
|
383
391
|
}
|
|
384
392
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
393
|
+
// ── transcript rendering (#660) ────────────────────────────────────────────────────────────────
|
|
394
|
+
//
|
|
395
|
+
// A rendered-transcript sink over `host`: it accumulates the relay stream's `{offset, chunk}` entries
|
|
396
|
+
// and (re-)draws them through the shared `renderDerivedTranscript` — the SAME parse→derive→render path
|
|
397
|
+
// the typed core uses — so the operator sees readable message turns + tool/diff/permission cards, never
|
|
398
|
+
// the raw `nwfTranscriptEvent` envelopes. Chunks are keyed by offset, so a resume-from-offset reconnect
|
|
399
|
+
// that re-delivers a chunk coalesces instead of doubling it (mirrors TerminalSession's own dedup). The
|
|
400
|
+
// derive/render logic is NOT re-copied here — that hand-copy was the #660 drift; this only collects
|
|
401
|
+
// offsets and hands the whole page to the imported renderer, which parses + folds it exactly once.
|
|
402
|
+
function transcriptSink(host, stream, opts = {}) {
|
|
403
|
+
const byOffset = new Map();
|
|
404
|
+
let gap = false;
|
|
405
|
+
let nextOffset = 0;
|
|
406
|
+
const draw = () => {
|
|
407
|
+
const entries = [...byOffset.entries()].sort((a, b) => a[0] - b[0]).map(([offset, chunk]) => ({ offset, chunk }));
|
|
408
|
+
renderDerivedTranscript(host, host.ownerDocument, { stream, from: 0, gap, nextOffset, entries }, opts);
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
/** Fold in the resume ack (offset/gap metadata) and redraw. */
|
|
412
|
+
ack: (message) => {
|
|
413
|
+
if (typeof message?.nextOffset === "number") nextOffset = message.nextOffset;
|
|
414
|
+
if (typeof message?.gap === "boolean") gap = message.gap;
|
|
415
|
+
draw();
|
|
416
|
+
},
|
|
417
|
+
/** Fold in one relay data chunk (deduped by offset) and redraw. */
|
|
418
|
+
add: (offset, chunk) => {
|
|
419
|
+
byOffset.set(offset, chunk);
|
|
420
|
+
draw();
|
|
421
|
+
},
|
|
422
|
+
/** Render a whole fetched transcript page in one pass (static replay). */
|
|
423
|
+
replace: (data) => {
|
|
424
|
+
byOffset.clear();
|
|
425
|
+
gap = Boolean(data.gap);
|
|
426
|
+
nextOffset = data.nextOffset ?? 0;
|
|
427
|
+
for (const entry of data.entries ?? []) byOffset.set(entry.offset, entry.chunk);
|
|
428
|
+
draw();
|
|
429
|
+
},
|
|
430
|
+
};
|
|
391
431
|
}
|
|
392
432
|
|
|
393
433
|
// ── boot orchestration (mirrors app/agentic/cockpit/supply-boot.ts) ────────────────────────────
|
|
394
434
|
|
|
395
|
-
/** An xterm.js-backed terminal sink mounted into `host`. */
|
|
396
|
-
function xtermSink(host) {
|
|
397
|
-
const term = new Terminal({ convertEol: true, fontFamily: "ui-monospace, monospace", fontSize: 13 });
|
|
398
|
-
term.open(host);
|
|
399
|
-
return { write: (chunk) => term.write(chunk), dispose: () => term.dispose() };
|
|
400
|
-
}
|
|
401
|
-
|
|
402
435
|
/** A WebSocket relay socket factory for the agentic channel at `url`. */
|
|
403
436
|
function relaySocketFactory(url) {
|
|
404
437
|
return () => {
|
|
@@ -487,9 +520,11 @@ export function mountCockpit(host, opts = {}) {
|
|
|
487
520
|
return headers;
|
|
488
521
|
};
|
|
489
522
|
|
|
490
|
-
// Stable skeleton: a volatile supply-list region
|
|
491
|
-
//
|
|
492
|
-
//
|
|
523
|
+
// Stable skeleton: a volatile supply-list region the poll re-renders, then the PERSISTENT terminal
|
|
524
|
+
// region a refresh never touches (so a drilled-in/replayed transcript survives a list refresh), then
|
|
525
|
+
// the volatile "past sessions" region. Ordered so the rendered transcript sits DIRECTLY BENEATH the
|
|
526
|
+
// "Workers — supply" table (#660), with past-sessions relocated below it. The terminal panel title
|
|
527
|
+
// distinguishes live vs replayed.
|
|
493
528
|
host.replaceChildren();
|
|
494
529
|
const shell = el(doc, "div", "cockpit-shell");
|
|
495
530
|
const listRegion = el(doc, "div", "cockpit-supply-region");
|
|
@@ -508,8 +543,8 @@ export function mountCockpit(host, opts = {}) {
|
|
|
508
543
|
terminalNote.setAttribute("data-terminal-note", "none");
|
|
509
544
|
terminalPanel.appendChild(terminalNote);
|
|
510
545
|
shell.appendChild(listRegion);
|
|
511
|
-
shell.appendChild(pastRegion);
|
|
512
546
|
shell.appendChild(terminalPanel);
|
|
547
|
+
shell.appendChild(pastRegion);
|
|
513
548
|
host.appendChild(shell);
|
|
514
549
|
|
|
515
550
|
let running = false;
|
|
@@ -517,7 +552,7 @@ export function mountCockpit(host, opts = {}) {
|
|
|
517
552
|
let timer;
|
|
518
553
|
let generation = 0;
|
|
519
554
|
let drill; // { stream, client }
|
|
520
|
-
let terminal; // the current
|
|
555
|
+
let terminal; // the current rendered-transcript sink
|
|
521
556
|
let mode; // "live" | "replay" | undefined
|
|
522
557
|
let shownStream;
|
|
523
558
|
let route = parseCockpitRoute(location.hash);
|
|
@@ -557,6 +592,7 @@ export function mountCockpit(host, opts = {}) {
|
|
|
557
592
|
drill = undefined;
|
|
558
593
|
terminal?.dispose?.();
|
|
559
594
|
terminal = undefined;
|
|
595
|
+
terminalHost.replaceChildren();
|
|
560
596
|
}
|
|
561
597
|
|
|
562
598
|
function routeInstance() {
|
|
@@ -612,19 +648,12 @@ export function mountCockpit(host, opts = {}) {
|
|
|
612
648
|
teardownTerminal();
|
|
613
649
|
try {
|
|
614
650
|
terminalHost.replaceChildren();
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
//
|
|
651
|
+
// Render the LIVE stream through the shared derive+render path (#660): each relay chunk is folded
|
|
652
|
+
// into a rendered transcript (message turns + tool/diff/permission cards), never dumped as raw
|
|
653
|
+
// `nwfTranscriptEvent` JSON. The sink accumulates offsets and redraws via `renderDerivedTranscript`.
|
|
654
|
+
const rendered = transcriptSink(terminalHost, stream);
|
|
655
|
+
terminal = rendered;
|
|
618
656
|
let cleared = false;
|
|
619
|
-
const sink = {
|
|
620
|
-
write: (chunk) => {
|
|
621
|
-
if (!cleared) {
|
|
622
|
-
cleared = true;
|
|
623
|
-
setNote(undefined);
|
|
624
|
-
}
|
|
625
|
-
rawSink.write(chunk);
|
|
626
|
-
},
|
|
627
|
-
};
|
|
628
657
|
let session;
|
|
629
658
|
const client = new RelayChannelClient({
|
|
630
659
|
connect: connectRelay,
|
|
@@ -633,13 +662,28 @@ export function mountCockpit(host, opts = {}) {
|
|
|
633
662
|
// then it honestly reads "connecting", so a socket that never opens/subscribes stops
|
|
634
663
|
// masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's
|
|
635
664
|
// resubscribe ack does not re-arm the note after real output has already flowed.
|
|
636
|
-
if (
|
|
665
|
+
if (message?.op === "subscribed") {
|
|
666
|
+
if (!cleared) setNote("Waiting for live output…", "waiting");
|
|
667
|
+
rendered.ack(message);
|
|
668
|
+
} else if (message != null && typeof message.offset === "number" && typeof message.chunk === "string") {
|
|
669
|
+
// The first data chunk clears the "waiting" note and folds into the rendered transcript.
|
|
670
|
+
// Guard on `typeof chunk === "string"`: the derive/parser assumes string chunks (e.g.
|
|
671
|
+
// `trimStart()`), so a non-string/binary frame is dropped here rather than thrown downstream.
|
|
672
|
+
if (!cleared) {
|
|
673
|
+
cleared = true;
|
|
674
|
+
setNote(undefined);
|
|
675
|
+
}
|
|
676
|
+
rendered.add(message.offset, message.chunk);
|
|
677
|
+
}
|
|
678
|
+
// Still drive the TerminalSession so its subscribe/credit/resume-from-offset protocol runs
|
|
679
|
+
// (it is what makes the relay deliver chunks and survive a reconnect); its sink is a no-op
|
|
680
|
+
// because the rendered transcript above — not a byte terminal — is the operator's view.
|
|
637
681
|
session?.handle(message);
|
|
638
682
|
},
|
|
639
683
|
onOpen: () => session?.attach(),
|
|
640
684
|
onError,
|
|
641
685
|
});
|
|
642
|
-
session = new TerminalSession({ stream, sink, send: (message) => client.sendRelay(message) });
|
|
686
|
+
session = new TerminalSession({ stream, sink: { write: () => {} }, send: (message) => client.sendRelay(message) });
|
|
643
687
|
client.open();
|
|
644
688
|
drill = { stream, client };
|
|
645
689
|
setMode("live", stream);
|
|
@@ -689,10 +733,12 @@ export function mountCockpit(host, opts = {}) {
|
|
|
689
733
|
if (disposed || token !== opToken) return;
|
|
690
734
|
try {
|
|
691
735
|
terminalHost.replaceChildren();
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
736
|
+
// Render the fetched past-session transcript through the SAME derive+render path as the live
|
|
737
|
+
// drill (#660): the stored `nwfTranscriptEvent` chunks become message turns + tool/diff/permission
|
|
738
|
+
// cards, never a raw JSON dump.
|
|
739
|
+
const rendered = transcriptSink(terminalHost, stream);
|
|
740
|
+
terminal = rendered;
|
|
741
|
+
rendered.replace(data);
|
|
696
742
|
setMode("replay", stream);
|
|
697
743
|
void refreshPast(routeInstance());
|
|
698
744
|
} catch (err) {
|
|
@@ -5,22 +5,21 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
6
|
<title>Agent cockpit — supply</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
|
<!--
|
|
13
|
-
Resolve the reusable cockpit ESM (the relay client + resume-from-offset terminal session)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
the standalone phone view and the embedded console
|
|
12
|
+
Resolve the reusable cockpit ESM (the relay client + resume-from-offset terminal session). Only
|
|
13
|
+
these correctness-critical primitives are imported from the package; the supply projection/render/
|
|
14
|
+
poll lives in ./mount.js, and the transcript is rendered from the typed core's build-step emit
|
|
15
|
+
under ./generated/ (imported by ./mount.js) — no runtime CDN dep (#660). The SAME ./mount.js the
|
|
16
|
+
console App-View embed uses is loaded here, so the standalone phone view and the embedded console
|
|
17
|
+
view render identically.
|
|
18
18
|
-->
|
|
19
19
|
<script type="importmap">
|
|
20
20
|
{
|
|
21
21
|
"imports": {
|
|
22
|
-
"@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js"
|
|
23
|
-
"@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/+esm"
|
|
22
|
+
"@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js"
|
|
24
23
|
}
|
|
25
24
|
}
|
|
26
25
|
</script>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Build step (#660): emit the browser-consumable ESM of the typed transcript derive+render core so the
|
|
2
|
+
// deployed cockpit adapter (`pages/cockpit/mount.js`) can RENDER the agentic transcript from the SAME
|
|
3
|
+
// source of truth the Node/TypeScript core and its tests use — instead of hand-copying the derive
|
|
4
|
+
// logic (the drift surface that made the Worker terminal dump raw `nwfTranscriptEvent` JSON).
|
|
5
|
+
//
|
|
6
|
+
// The app has no bundler, so the browser cannot import the `.ts` core directly. Rather than duplicate
|
|
7
|
+
// the parse→derive→render path by hand in `mount.js` again, we DERIVE the browser bundle from the ONE
|
|
8
|
+
// typed source: each module is transpiled (type-strip only — no second implementation) to plain ESM
|
|
9
|
+
// under `pages/cockpit/generated/`, which `mount.js` imports. A drift-guard test regenerates in memory
|
|
10
|
+
// and asserts the committed output is byte-identical, so the generated files can never silently drift
|
|
11
|
+
// from their `.ts` origin (Derivation Over Duplication).
|
|
12
|
+
//
|
|
13
|
+
// Run `node --experimental-strip-types scripts/build-cockpit-browser.ts` to (re)generate, or
|
|
14
|
+
// `… --check` to fail if the committed output is stale.
|
|
15
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import ts from "typescript";
|
|
19
|
+
|
|
20
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
|
|
22
|
+
/** One typed core module → its generated browser-ESM sibling, with any relative import specifiers rewritten. */
|
|
23
|
+
interface BundleModule {
|
|
24
|
+
/** Source `.ts`, repo-relative. */
|
|
25
|
+
readonly src: string;
|
|
26
|
+
/** Emitted `.js`, repo-relative (under pages/ so the browser serves it beside mount.js). */
|
|
27
|
+
readonly out: string;
|
|
28
|
+
/** Literal import-specifier rewrites applied to the emitted JS (`.ts` core path → generated `.js` sibling). */
|
|
29
|
+
readonly rewrites?: ReadonlyArray<readonly [from: string, to: string]>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The transcript RENDER path the browser needs is a two-module graph, both pure + DOM-agnostic:
|
|
33
|
+
// transcript-events.ts — the ONE parser + derive() fold (no runtime imports).
|
|
34
|
+
// transcript-derive.ts — renderDerivedTranscript(): message turns, tool/diff cards, permission prompts.
|
|
35
|
+
// Their only non-type imports are between each other; every type-only import (DocumentLike, ElementLike,
|
|
36
|
+
// TranscriptDataReport) is `import type` and is erased on transpile, so the emitted JS is import-clean.
|
|
37
|
+
const MODULES: readonly BundleModule[] = [
|
|
38
|
+
{ src: "app/agentic/transcript-events.ts", out: "pages/cockpit/generated/transcript-events.js" },
|
|
39
|
+
{
|
|
40
|
+
src: "app/agentic/cockpit/transcript-derive.ts",
|
|
41
|
+
out: "pages/cockpit/generated/transcript-derive.js",
|
|
42
|
+
rewrites: [["../transcript-events.ts", "./transcript-events.js"]],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function banner(src: string): string {
|
|
47
|
+
return [
|
|
48
|
+
`// @generated from ${src} by scripts/build-cockpit-browser.ts — DO NOT EDIT.`,
|
|
49
|
+
"//",
|
|
50
|
+
"// Browser ESM derived (type-strip only) from the typed transcript core so pages/cockpit/mount.js",
|
|
51
|
+
"// renders the agentic transcript from ONE source of truth (#660). Regenerate with:",
|
|
52
|
+
"// node --experimental-strip-types scripts/build-cockpit-browser.ts",
|
|
53
|
+
"",
|
|
54
|
+
"",
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function transpile(src: string): string {
|
|
59
|
+
const source = readFileSync(resolve(repoRoot, src), "utf8");
|
|
60
|
+
const result = ts.transpileModule(source, {
|
|
61
|
+
compilerOptions: {
|
|
62
|
+
module: ts.ModuleKind.ESNext,
|
|
63
|
+
target: ts.ScriptTarget.ES2022,
|
|
64
|
+
verbatimModuleSyntax: false,
|
|
65
|
+
removeComments: false,
|
|
66
|
+
},
|
|
67
|
+
fileName: src,
|
|
68
|
+
});
|
|
69
|
+
return result.outputText;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Generate the browser bundle in memory — the single source both the writer and the drift-guard test use. */
|
|
73
|
+
export function cockpitBrowserBundle(): ReadonlyArray<{ readonly out: string; readonly content: string }> {
|
|
74
|
+
return MODULES.map((mod) => {
|
|
75
|
+
let content = transpile(mod.src);
|
|
76
|
+
for (const [from, to] of mod.rewrites ?? []) {
|
|
77
|
+
content = content.split(from).join(to);
|
|
78
|
+
}
|
|
79
|
+
return { out: mod.out, content: banner(mod.src) + content };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function main(): void {
|
|
84
|
+
const check = process.argv.includes("--check");
|
|
85
|
+
let stale = false;
|
|
86
|
+
for (const { out, content } of cockpitBrowserBundle()) {
|
|
87
|
+
const path = resolve(repoRoot, out);
|
|
88
|
+
if (check) {
|
|
89
|
+
let current: string | undefined;
|
|
90
|
+
try {
|
|
91
|
+
current = readFileSync(path, "utf8");
|
|
92
|
+
} catch {
|
|
93
|
+
current = undefined;
|
|
94
|
+
}
|
|
95
|
+
if (current !== content) {
|
|
96
|
+
stale = true;
|
|
97
|
+
console.error(`✗ ${out} is stale — run: node --experimental-strip-types scripts/build-cockpit-browser.ts`);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
writeFileSync(path, content);
|
|
101
|
+
console.log(`✓ wrote ${out}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (check && stale) process.exit(1);
|
|
105
|
+
if (check) console.log("✓ cockpit browser bundle is up to date");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
109
|
+
main();
|
|
110
|
+
}
|