@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 shows a 'waiting for output' note until the first frame, then clears it", async () => {
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
- assert.equal(note()?.getAttribute("data-terminal-note"), "waiting", "note armed on a connected-but-quiet stream");
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: 0, payload: { stream: "wk-a", offset: 0, chunk: "hi\n" } });
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("switching to a new stream re-arms the 'waiting' note (the prior stream's cleared note does not persist)", async () => {
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"), "waiting", "the new drill re-arms the waiting 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 "connected, waiting for output" affordance. */
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", "waiting");
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) => session?.handle(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 "waiting for output" note (after #setMode, which clears it): shown until the first
548
- // frame is written, so a connected-but-quiet stream reads as "waiting", not "broken".
549
- this.#setNote("Connected waiting for live output…");
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.0",
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",
@@ -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", "waiting");
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) => session?.handle(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 "waiting for output" note (after setMode, which clears it) until the first frame.
640
- setNote("Connected waiting for live output");
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 from the current origin (path `/agentic`).
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
- const proto = location.protocol === "https:" ? "wss:" : "ws:";
828
- const base = `${proto}//${location.host}/agentic`;
829
- if (!token) return base;
830
- const query = new URLSearchParams({ token });
831
- if (capability) query.set("capability", capability);
832
- return `${base}?${query}`;
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
+ });