@nanobpm/nano-workforce 0.158.0 → 0.158.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.158.1](https://github.com/nanobpm/nano-workforce/compare/v0.158.0...v0.158.1) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **cockpit:** derive relay URL from import.meta.url, not location.host ([#600](https://github.com/nanobpm/nano-workforce/issues/600)) ([#612](https://github.com/nanobpm/nano-workforce/issues/612)) ([69923fa](https://github.com/nanobpm/nano-workforce/commit/69923fad688aaa849302be8a1906dd7c6f938c49))
|
|
6
|
+
|
|
1
7
|
## [0.158.0](https://github.com/nanobpm/nano-workforce/compare/v0.157.0...v0.158.0) (2026-08-29)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -164,23 +164,47 @@ test("relay output is written to the drilled worker's terminal", async () => {
|
|
|
164
164
|
assert.deepEqual(r.terminalWrites, ["boot\n"]);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
test("a live drill
|
|
167
|
+
test("a live drill reads 'connecting', promotes to 'waiting' on the subscribe ack, then clears on the first frame", async () => {
|
|
168
168
|
const r = rig();
|
|
169
169
|
const cockpit = bootSupplyCockpit(r.env);
|
|
170
170
|
await cockpit.refresh();
|
|
171
171
|
const note = () => r.host.byClass("cockpit-terminal-note")[0];
|
|
172
172
|
|
|
173
173
|
cockpit.drill("wk-a");
|
|
174
|
+
// Before the socket opens/subscribes the note honestly reads "connecting" — not a false "connected".
|
|
175
|
+
assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "note armed as connecting before the socket opens");
|
|
176
|
+
assert.match(note()?.textContent ?? "", /connecting/i);
|
|
177
|
+
|
|
174
178
|
r.sockets[0]?.fireOpen();
|
|
175
|
-
|
|
179
|
+
// The hub ACKs the subscribe → the note promotes to "waiting for live output" on a connected-but-quiet stream.
|
|
180
|
+
r.sockets[0]?.deliver({ lane: "control", family: "relay", seq: 0, payload: { op: "subscribed", stream: "wk-a", gap: false, nextOffset: 0 } });
|
|
181
|
+
assert.equal(note()?.getAttribute("data-terminal-note"), "waiting", "note promoted to waiting once the subscribe is acked");
|
|
176
182
|
assert.match(note()?.textContent ?? "", /waiting for live output/i);
|
|
177
183
|
|
|
178
|
-
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq:
|
|
184
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "wk-a", offset: 0, chunk: "hi\n" } });
|
|
179
185
|
assert.equal(note()?.getAttribute("data-terminal-note"), "none", "note cleared the instant the first frame is written");
|
|
180
186
|
assert.equal(note()?.textContent, "");
|
|
181
187
|
});
|
|
182
188
|
|
|
183
|
-
test("
|
|
189
|
+
test("a drill whose socket never opens surfaces 'connecting', never a false 'connected'/'waiting' (#600)", async () => {
|
|
190
|
+
const r = rig();
|
|
191
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
192
|
+
await cockpit.refresh();
|
|
193
|
+
const note = () => r.host.byClass("cockpit-terminal-note")[0];
|
|
194
|
+
|
|
195
|
+
// A dead relay target (the #600 symptom: reconnect-looping, never subscribed) — the socket is created
|
|
196
|
+
// but never fires open, so the subscribe is never acked. The note must NOT claim the stream connected.
|
|
197
|
+
cockpit.drill("wk-a");
|
|
198
|
+
assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "a socket that never opens reads as connecting, not connected");
|
|
199
|
+
assert.match(note()?.textContent ?? "", /connecting/i);
|
|
200
|
+
assert.doesNotMatch(
|
|
201
|
+
note()?.textContent ?? "",
|
|
202
|
+
/connected|waiting for live output/i,
|
|
203
|
+
"a dead socket must not masquerade as a connected-but-quiet stream (#600)",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("switching to a new stream re-arms the note (the prior stream's cleared note does not persist)", async () => {
|
|
184
208
|
const r = rig();
|
|
185
209
|
const cockpit = bootSupplyCockpit(r.env);
|
|
186
210
|
await cockpit.refresh();
|
|
@@ -192,7 +216,7 @@ test("switching to a new stream re-arms the 'waiting' note (the prior stream's c
|
|
|
192
216
|
assert.equal(note()?.getAttribute("data-terminal-note"), "none");
|
|
193
217
|
|
|
194
218
|
cockpit.drill("wk-b");
|
|
195
|
-
assert.equal(note()?.getAttribute("data-terminal-note"), "
|
|
219
|
+
assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "the new drill re-arms the note (connecting), not stuck on the prior stream's cleared none");
|
|
196
220
|
});
|
|
197
221
|
|
|
198
222
|
test("the terminal survives a list refresh — it is not re-mounted and keeps streaming", async () => {
|
|
@@ -314,15 +314,15 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
314
314
|
if (mode !== "replay") this.#structuredRegion?.replaceChildren();
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
/** Show (or clear) the terminal status note — the "
|
|
318
|
-
#setNote(text: string | undefined): void {
|
|
317
|
+
/** Show (or clear) the terminal status note — the "connecting" / "waiting for output" affordance. */
|
|
318
|
+
#setNote(text: string | undefined, state: "connecting" | "waiting" = "waiting"): void {
|
|
319
319
|
if (text === undefined) {
|
|
320
320
|
this.#terminalNote.textContent = "";
|
|
321
321
|
this.#terminalNote.setAttribute("data-terminal-note", "none");
|
|
322
322
|
return;
|
|
323
323
|
}
|
|
324
324
|
this.#terminalNote.textContent = text;
|
|
325
|
-
this.#terminalNote.setAttribute("data-terminal-note",
|
|
325
|
+
this.#terminalNote.setAttribute("data-terminal-note", state);
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
async refresh(): Promise<void> {
|
|
@@ -528,7 +528,16 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
528
528
|
let session: TerminalSession | undefined;
|
|
529
529
|
const client = new RelayChannelClient({
|
|
530
530
|
connect: this.#env.connectRelay,
|
|
531
|
-
onRelay: (message) =>
|
|
531
|
+
onRelay: (message) => {
|
|
532
|
+
// Promote the note to "waiting for live output" only once the hub ACKs the subscribe. Until
|
|
533
|
+
// then it honestly reads "connecting", so a socket that never opens/subscribes stops
|
|
534
|
+
// masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's
|
|
535
|
+
// resubscribe ack does not re-arm the note after real output has already flowed.
|
|
536
|
+
if (!cleared && "op" in message && message.op === "subscribed") {
|
|
537
|
+
this.#setNote("Waiting for live output…", "waiting");
|
|
538
|
+
}
|
|
539
|
+
session?.handle(message);
|
|
540
|
+
},
|
|
532
541
|
// Re-attach on EVERY (re)connect → resume-from-offset: the terminal survives a cockpit
|
|
533
542
|
// reconnect without losing or double-writing output.
|
|
534
543
|
onOpen: () => session?.attach(),
|
|
@@ -544,9 +553,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
544
553
|
client.open();
|
|
545
554
|
this.#drill = { stream, client };
|
|
546
555
|
this.#setMode("live", stream);
|
|
547
|
-
// Arm the
|
|
548
|
-
//
|
|
549
|
-
|
|
556
|
+
// Arm the note as "connecting" (after #setMode, which clears it) BEFORE the socket opens. It only
|
|
557
|
+
// becomes "waiting for live output" when the subscribe is ACKed (onRelay above) and clears on the
|
|
558
|
+
// first byte, so a dead socket reads as "connecting", never a falsely-"connected" quiet stream (#600).
|
|
559
|
+
this.#setNote("Connecting…", "connecting");
|
|
550
560
|
} catch (err) {
|
|
551
561
|
// Building the new terminal failed AFTER the prior drill + terminal were already torn down
|
|
552
562
|
// above. Leaving #mode/#shownStream at their prior value would keep the panel showing a stale
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.158.
|
|
3
|
+
"version": "0.158.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",
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -542,14 +542,14 @@ export function mountCockpit(host, opts = {}) {
|
|
|
542
542
|
setNote(undefined);
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
-
function setNote(text) {
|
|
545
|
+
function setNote(text, state = "waiting") {
|
|
546
546
|
if (text == null) {
|
|
547
547
|
terminalNote.textContent = "";
|
|
548
548
|
terminalNote.setAttribute("data-terminal-note", "none");
|
|
549
549
|
return;
|
|
550
550
|
}
|
|
551
551
|
terminalNote.textContent = text;
|
|
552
|
-
terminalNote.setAttribute("data-terminal-note",
|
|
552
|
+
terminalNote.setAttribute("data-terminal-note", state);
|
|
553
553
|
}
|
|
554
554
|
|
|
555
555
|
function teardownTerminal() {
|
|
@@ -628,7 +628,14 @@ export function mountCockpit(host, opts = {}) {
|
|
|
628
628
|
let session;
|
|
629
629
|
const client = new RelayChannelClient({
|
|
630
630
|
connect: connectRelay,
|
|
631
|
-
onRelay: (message) =>
|
|
631
|
+
onRelay: (message) => {
|
|
632
|
+
// Promote the note to "waiting for live output" only once the hub ACKs the subscribe. Until
|
|
633
|
+
// then it honestly reads "connecting", so a socket that never opens/subscribes stops
|
|
634
|
+
// masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's
|
|
635
|
+
// resubscribe ack does not re-arm the note after real output has already flowed.
|
|
636
|
+
if (!cleared && message?.op === "subscribed") setNote("Waiting for live output…", "waiting");
|
|
637
|
+
session?.handle(message);
|
|
638
|
+
},
|
|
632
639
|
onOpen: () => session?.attach(),
|
|
633
640
|
onError,
|
|
634
641
|
});
|
|
@@ -636,8 +643,10 @@ export function mountCockpit(host, opts = {}) {
|
|
|
636
643
|
client.open();
|
|
637
644
|
drill = { stream, client };
|
|
638
645
|
setMode("live", stream);
|
|
639
|
-
// Arm the
|
|
640
|
-
|
|
646
|
+
// Arm the note as "connecting" (after setMode, which clears it) BEFORE the socket opens. It only
|
|
647
|
+
// becomes "waiting for live output" when the subscribe is ACKed (onRelay above) and clears on the
|
|
648
|
+
// first byte, so a dead socket reads as "connecting", never a falsely-"connected" quiet stream (#600).
|
|
649
|
+
setNote("Connecting…", "connecting");
|
|
641
650
|
} catch (err) {
|
|
642
651
|
// The new terminal failed to build after the prior one was torn down: reset the region to idle
|
|
643
652
|
// (and drop any partially-built terminal) so the UI never shows a stale "live"/"replay"
|
|
@@ -811,7 +820,7 @@ export function mountCockpit(host, opts = {}) {
|
|
|
811
820
|
}
|
|
812
821
|
|
|
813
822
|
/**
|
|
814
|
-
* Derive the channel WebSocket URL
|
|
823
|
+
* Derive the channel WebSocket URL module-relatively (path `/agentic`, anchored to import.meta.url).
|
|
815
824
|
*
|
|
816
825
|
* The agentic hub authenticates upgrades with an identity token only
|
|
817
826
|
* (`sharedSecretAuthenticator({ requireCredential: false })`) — no capability credential is required.
|
|
@@ -824,10 +833,21 @@ export function mountCockpit(host, opts = {}) {
|
|
|
824
833
|
* token (or an explicit `relayUrl`) for secured deployments.
|
|
825
834
|
*/
|
|
826
835
|
function defaultRelayUrl(token, capability) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
836
|
+
// Anchor the relay endpoint to THIS MODULE's url (import.meta.url), NOT location.host — exactly as
|
|
837
|
+
// the HTTP endpoints (reportUrl/transcriptsUrl) are (#279/#467). mount.js is ALWAYS served at
|
|
838
|
+
// `<appMount>/cockpit/mount.js`, so `../agentic` resolves to `<appMount>/agentic` on every surface,
|
|
839
|
+
// then swap the scheme http→ws / https→wss:
|
|
840
|
+
// • standalone / local App-View: `ws://<app-origin>/agentic` — byte-identical to the old behaviour.
|
|
841
|
+
// • Studio console App-View: `ws://<console>/console/app-view/<app>/agentic` — the app-view
|
|
842
|
+
// -prefixed path the console proxies, NOT the console origin root.
|
|
843
|
+
// Deriving from `location.host` instead dialed the CONSOLE origin behind the App-View — which has no
|
|
844
|
+
// `/agentic` route (404) and whose app-view proxy refuses WS upgrades (501, ADR 0057 §3) — leaving
|
|
845
|
+
// the live terminal permanently dead behind the console (#600). This is the WS hop of the same
|
|
846
|
+
// failure class the HTTP endpoints already fixed by anchoring to import.meta.url.
|
|
847
|
+
const url = new URL("../agentic", import.meta.url);
|
|
848
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
849
|
+
if (!token) return url.href;
|
|
850
|
+
url.searchParams.set("token", token);
|
|
851
|
+
if (capability) url.searchParams.set("capability", capability);
|
|
852
|
+
return url.href;
|
|
833
853
|
}
|
|
@@ -94,3 +94,87 @@ test("#279: embed.html forwards BOTH reportUrl and transcriptsUrl from the injec
|
|
|
94
94
|
"embed.html must forward reportUrl: cfg.reportUrl",
|
|
95
95
|
);
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
// #600: the relay WebSocket default is the WS hop of the SAME #279/#467 failure class. It used to be
|
|
99
|
+
// derived from `location.host` — behind the Studio console that is the console origin (:8080), which
|
|
100
|
+
// has no `/agentic` route (404) and whose app-view proxy refuses WS upgrades (501, ADR 0057 §3), so
|
|
101
|
+
// the live terminal was permanently dead behind the console. The fix anchors it to import.meta.url
|
|
102
|
+
// exactly like the HTTP endpoints, `../agentic` off `<appMount>/cockpit/mount.js`, then swaps the
|
|
103
|
+
// scheme http→ws / https→wss. Unlike reportUrl/transcriptsUrl (inlined `.href`) it is BUILT from
|
|
104
|
+
// `new URL("<spec>", import.meta.url)` so the scheme can be swapped, so it is matched separately.
|
|
105
|
+
|
|
106
|
+
// Pull the module-relative relay spec out of `new URL("<spec>", import.meta.url);` in defaultRelayUrl()
|
|
107
|
+
// (the HTTP defaults are `new URL(..., import.meta.url).href`, so `)\s*;` matches only the relay one).
|
|
108
|
+
function relaySpec(): string {
|
|
109
|
+
const m = MOUNT_JS.match(/new URL\(\s*"([^"]*)"\s*,\s*import\.meta\.url\s*\)\s*;/);
|
|
110
|
+
assert(
|
|
111
|
+
m,
|
|
112
|
+
"mount.js defaultRelayUrl() must derive the relay default from new URL(\"<spec>\", import.meta.url), " +
|
|
113
|
+
"anchored to the module's own served location, not location.host (#600)",
|
|
114
|
+
);
|
|
115
|
+
return m![1];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Resolve the relay spec against a REAL served mount url and swap the scheme, mirroring defaultRelayUrl().
|
|
119
|
+
function resolveRelay(mount: string): string {
|
|
120
|
+
const url = new URL(relaySpec(), mount);
|
|
121
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
122
|
+
return url.href;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test("#600: defaultRelayUrl derives the relay target from import.meta.url, not location.host", () => {
|
|
126
|
+
assert(
|
|
127
|
+
!/\$\{location\.host\}/.test(MOUNT_JS),
|
|
128
|
+
"the relay default must not be interpolated from location.host: behind the console that is the " +
|
|
129
|
+
"console origin (:8080), which has no /agentic route (404) and refuses WS upgrades (501) (#600)",
|
|
130
|
+
);
|
|
131
|
+
const spec = relaySpec();
|
|
132
|
+
assert(
|
|
133
|
+
!spec.startsWith("/"),
|
|
134
|
+
`relay spec "${spec}" must not be absolute: a leading-slash path resolves against the iframe ORIGIN ` +
|
|
135
|
+
`(console :8080), not the app-view base the console proxies (#279 class)`,
|
|
136
|
+
);
|
|
137
|
+
assert(
|
|
138
|
+
spec.startsWith("../"),
|
|
139
|
+
`relay spec "${spec}" must step up out of /cockpit/ (mount.js is at <appMount>/cockpit/mount.js; the ` +
|
|
140
|
+
`hub is a sibling at <appMount>/agentic) (#467 class)`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("#600: relay default resolves to ws://<app-origin>/agentic standalone (behaviour unchanged)", () => {
|
|
145
|
+
assertEquals(
|
|
146
|
+
resolveRelay(STANDALONE_MOUNT),
|
|
147
|
+
"ws://127.0.0.1:3000/agentic",
|
|
148
|
+
"standalone the relay default must dial the app origin's /agentic exactly as it does today",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("#600: relay default resolves onto the app-view base inside the Studio console iframe", () => {
|
|
153
|
+
assertEquals(
|
|
154
|
+
resolveRelay(STUDIO_MOUNT),
|
|
155
|
+
"ws://studio-host:8080/console/app-view/Workforce/agentic",
|
|
156
|
+
"behind the console the relay must dial the app-view-prefixed /agentic the console proxies, not the " +
|
|
157
|
+
"console origin root (#279 class) nor the /cockpit/ shell base (#467 class)",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("#600: relay default swaps https→wss on a secure surface", () => {
|
|
162
|
+
assertEquals(
|
|
163
|
+
resolveRelay("https://studio-host:8443/console/app-view/Workforce/cockpit/mount.js"),
|
|
164
|
+
"wss://studio-host:8443/console/app-view/Workforce/agentic",
|
|
165
|
+
"an https surface must yield a wss relay url",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("#600: an explicit relayUrl opt and injected __NANO_APP_VIEW__.relayUrl outrank the derived default", () => {
|
|
170
|
+
// Precedence is pinned in source: opts.relayUrl wins over the module-relative default, and embed.html
|
|
171
|
+
// forwards the console-injected cfg.relayUrl into that opt so it outranks the default behind the console.
|
|
172
|
+
assert(
|
|
173
|
+
/opts\.relayUrl\s*\?\?\s*defaultRelayUrl\(/.test(MOUNT_JS),
|
|
174
|
+
"mount.js must honour an explicit opts.relayUrl over the derived default (opts.relayUrl ?? defaultRelayUrl(...)) (#600)",
|
|
175
|
+
);
|
|
176
|
+
assert(
|
|
177
|
+
/relayUrl:\s*cfg\.relayUrl/.test(EMBED_HTML),
|
|
178
|
+
"embed.html must forward relayUrl: cfg.relayUrl so a console-injected __NANO_APP_VIEW__.relayUrl reaches the relay (#600)",
|
|
179
|
+
);
|
|
180
|
+
});
|