@nanobpm/nano-workforce 0.182.1 → 0.182.3

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,15 @@
1
+ ## [0.182.3](https://github.com/nanobpm/nano-workforce/compare/v0.182.2...v0.182.3) (2026-09-06)
2
+
3
+ ### Bug Fixes
4
+
5
+ * cockpit historical transcript replay 404s through console gateway (encoded slash in stream-id path) ([#750](https://github.com/nanobpm/nano-workforce/issues/750)) ([8dd5e61](https://github.com/nanobpm/nano-workforce/commit/8dd5e619d664a50f513347f3b6f090d190495070)), closes [#744](https://github.com/nanobpm/nano-workforce/issues/744) [#744](https://github.com/nanobpm/nano-workforce/issues/744)
6
+
7
+ ## [0.182.2](https://github.com/nanobpm/nano-workforce/compare/v0.182.1...v0.182.2) (2026-09-04)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **deps:** update dependency @nanobpm/agentic to ^0.13.0 ([#421](https://github.com/nanobpm/nano-workforce/issues/421)) ([569f198](https://github.com/nanobpm/nano-workforce/commit/569f198cdd08423b284d1050fc42cdbb81937472))
12
+
1
13
  ## [0.182.1](https://github.com/nanobpm/nano-workforce/compare/v0.182.0...v0.182.1) (2026-09-04)
2
14
 
3
15
  ### Bug Fixes
@@ -70,12 +70,14 @@ function installEnv(fetchImpl: (url: string) => Promise<unknown>): () => void {
70
70
 
71
71
  const SUPPLY = { leaves: [], correlations: [] };
72
72
 
73
- /** A fetch stub answering the supply poll, the past-sessions list, and a single-stream replay. */
73
+ /** A fetch stub answering the supply poll, the past-sessions list, and a single-stream replay. The
74
+ * replay READ is matched by its `stream` query param — the proxy-safe form (#744) the deployed
75
+ * client builds; a slash-bearing id must never appear as a path segment. */
74
76
  function fetchStub(replay?: unknown) {
75
77
  return (url: string): Promise<unknown> => {
76
78
  const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
77
79
  if (url.includes("/supply")) return ok(SUPPLY);
78
- if (replay !== undefined && /\/transcripts\/[^/]+$/.test(url)) return ok(replay);
80
+ if (replay !== undefined && /[?&]stream=/.test(url)) return ok(replay);
79
81
  if (url.includes("/transcripts")) return ok({ sessions: [] });
80
82
  return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
81
83
  };
@@ -160,3 +162,54 @@ test("replay renders a past session's transcript — never a raw nwfTranscriptEv
160
162
  restore();
161
163
  }
162
164
  });
165
+
166
+ // #744 — the deployed cockpit's replay READ must be proxy-safe: the stream id rides the QUERY
167
+ // (`?stream=`), never a path segment. A worker-instance stream id contains a real `/`
168
+ // (`34:<instance>/<jobKey>`); the console gateway decodes an encoded %2F in a PATH segment back
169
+ // to `/` before the app routes, splitting the id into an extra segment → 404 {"error":"no such
170
+ // operation"} → replayInto's fetch throws → the terminal region renders empty ("nothing").
171
+ test("#744: replay fetches the proxy-safe ?stream= query form — a slash-bearing id never lands in a path segment", async () => {
172
+ const stream = "34:joshs-macbook-pro-copilot-3d6ee882/13859";
173
+ const replay = {
174
+ stream,
175
+ from: 0,
176
+ gap: false,
177
+ nextOffset: 1,
178
+ entries: [{ offset: 0, chunk: envChunk("message", { role: "user", text: "past session bytes" }) }],
179
+ };
180
+ const urls: string[] = [];
181
+ const stub = fetchStub(replay);
182
+ const restore = installEnv((url) => {
183
+ urls.push(url);
184
+ return stub(url);
185
+ });
186
+ try {
187
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
188
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
189
+ // Dispose in a finally: mountCockpit auto-starts a poll whose next-tick timer (refreshMs) is a
190
+ // live handle — a mid-test assertion failure that skipped dispose would hold the event loop
191
+ // open and hang the whole test runner.
192
+ try {
193
+ await handle.replay(stream);
194
+
195
+ // The auto-started supply poll and the past-sessions list also hit the wire; the READ fetch is
196
+ // the only one carrying the stream id (in either URL form — that's what's under test).
197
+ const readUrl = urls.find((u) => u.includes(encodeURIComponent(stream)) || u.includes(stream));
198
+ assert(readUrl !== undefined, `the replay fetched a transcript read URL for the stream (saw: ${urls.join(", ")})`);
199
+ const parsed = new URL(readUrl);
200
+ // The pathname STAYS the collection route: no %-encoded (or raw) slash-bearing id segment the
201
+ // gateway peel could split — this is the structural fix for the whole failure class, not just
202
+ // this one stream shape.
203
+ assertEquals(parsed.pathname, "/app/api/agentic/transcripts");
204
+ assertEquals(parsed.searchParams.get("stream"), stream, "the slash-bearing id round-trips intact as a query value");
205
+ // And the fetched bytes still render through the derive path.
206
+ const host = document.querySelector('[data-terminal="host"]');
207
+ assert((host?.textContent ?? "").includes("past session bytes"), "the past session rendered");
208
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
209
+ } finally {
210
+ handle.dispose();
211
+ }
212
+ } finally {
213
+ restore();
214
+ }
215
+ });
@@ -61,7 +61,11 @@ export interface SupplyCockpitEnv {
61
61
  */
62
62
  readonly fetchTranscripts?: (instance?: string) => Promise<TranscriptListReport>;
63
63
  /**
64
- * Fetches a stored transcript's bytes (`GET /agentic/transcripts/{stream}`) for static replay.
64
+ * Fetches a stored transcript's bytes for static replay. Production wiring hits the proxy-safe
65
+ * query form `GET /agentic/transcripts?stream=<id>&from=<n>` (#744 — see `transcriptReadUrlFor`
66
+ * in app/agentic/transcript-url.ts and its browser twin in pages/cockpit/mount.js): a
67
+ * slash-bearing stream id in a PATH segment (`GET /agentic/transcripts/{stream}`) is split by
68
+ * gateway proxies that decode %2F before routing, 404ing the read.
65
69
  * Required for the "past sessions" replay to work; must be provided together with {@link fetchTranscripts}.
66
70
  */
67
71
  readonly fetchTranscript?: (stream: string, from?: number) => Promise<TranscriptDataReport>;
@@ -15,9 +15,10 @@
15
15
  // unit-testable on the injected env (Node, no browser), and never touches the engine or a BPMN flow.
16
16
 
17
17
  import type { TranscriptChunk, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
18
- import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
18
+ import type { AgenticTranscript, AgenticTranscriptData, ErrorBody } from "../../nano-generated/api-io.d.ts";
19
19
  import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
20
20
  import type { AgenticCorrelationStore } from "./correlation-store.ts";
21
+ import type { RelayTranscriptService } from "./families/relay.family.ts";
21
22
  import { utf8ByteLength } from "./transcript-events.ts";
22
23
 
23
24
  /** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
@@ -242,3 +243,47 @@ export function readTranscriptFrom(
242
243
  if (fields.host !== undefined) out.host = fields.host;
243
244
  return out;
244
245
  }
246
+
247
+ /** The single-stream read result both transcript READ routes share (#744): the bytes on 200, the
248
+ * same 400 (malformed `from`) / 404 (no such stream, or no service mounted) outcomes on failure. */
249
+ export type SingleTranscriptResult =
250
+ | { status: 200; body: AgenticTranscriptData }
251
+ | { status: 400; body: ErrorBody }
252
+ | { status: 404; body: ErrorBody };
253
+
254
+ /**
255
+ * The ONE canonical single-stream transcript read, shared by BOTH routes that serve it (#744):
256
+ * `GET /agentic/transcripts?stream=<id>&from=<n>` (the proxy-safe QUERY form the cockpit clients
257
+ * build — a gateway that peels one percent-encoding layer before routing splits an encoded slash
258
+ * in a PATH segment into an extra segment and 404s, while a query value survives intact) and
259
+ * `GET /agentic/transcripts/{stream}` (the legacy path form the worker-emitted `transcriptUrl`
260
+ * resolves — safe there because `job:<jobKey>` ids structurally never contain a slash). One
261
+ * implementation so the two addressings can never answer differently for the same stream/from.
262
+ */
263
+ export function readSingleTranscript(
264
+ stream: string,
265
+ from: number | undefined,
266
+ service: RelayTranscriptService | undefined,
267
+ correlation: CorrelationRegistry | undefined,
268
+ ): SingleTranscriptResult {
269
+ const offset = from ?? 0;
270
+ if (!Number.isSafeInteger(offset) || offset < 0) {
271
+ return { status: 400, body: { error: "invalid from: expected a non-negative integer offset" } };
272
+ }
273
+ if (!service) {
274
+ // No relay/transcript service mounted at all - nothing to replay.
275
+ return { status: 404, body: { error: "no transcript for stream" } };
276
+ }
277
+ const data = readTranscriptFrom(
278
+ stream,
279
+ offset,
280
+ service.store,
281
+ correlation,
282
+ service.correlationStore,
283
+ service.liveFallback(stream),
284
+ );
285
+ if (data === undefined) {
286
+ return { status: 404, body: { error: "no transcript for stream" } };
287
+ }
288
+ return { status: 200, body: data };
289
+ }
@@ -7,6 +7,7 @@ import { jobStream } from "./correlation.ts";
7
7
  import {
8
8
  TRANSCRIPT_URL_BASE_VAR,
9
9
  TRANSCRIPT_URL_VAR,
10
+ transcriptReadUrlFor,
10
11
  transcriptUrlBaseFor,
11
12
  transcriptUrlForJob,
12
13
  } from "./transcript-url.ts";
@@ -33,3 +34,48 @@ test("derivation: transcriptUrlForJob is exactly transcriptUrlBaseFor + jobStrea
33
34
  const jobKey = "job-abc";
34
35
  assertEquals(transcriptUrlForJob(jobKey, BASE), `${transcriptUrlBaseFor(BASE)}${jobStream(jobKey)}`);
35
36
  });
37
+
38
+ // #744 — the proxy-safe single-stream READ form. A worker-instance stream id CONTAINS a slash
39
+ // (`34:<instance>/<jobKey>`); the Nano Console gateway proxy peels exactly one percent-encoding
40
+ // layer off the request before the app routes it, so an encoded slash (%2F) in a PATH segment
41
+ // arrives as a real / and splits the id into an extra segment — the app matches no route and
42
+ // answers 404 {"error":"no such operation"}. Carrying the id as a QUERY value makes the read
43
+ // structurally immune: a / is legal inside a query value, encoded or not.
44
+ const SLASH_STREAM = "34:joshs-macbook-pro-copilot-3d6ee882/13859";
45
+ const ENDPOINT = `${BASE}/app/api/agentic/transcripts`;
46
+
47
+ /** Non-empty path-segment count of a URL — what a route matcher counts. */
48
+ function pathSegments(u: string): number {
49
+ return new URL(u).pathname.split("/").filter((s) => s !== "").length;
50
+ }
51
+
52
+ test("transcriptReadUrlFor: the stream id rides the query, never a path segment (#744)", () => {
53
+ const url = new URL(transcriptReadUrlFor(ENDPOINT, SLASH_STREAM));
54
+ assertEquals(url.pathname, "/app/api/agentic/transcripts");
55
+ assertEquals(url.searchParams.get("stream"), SLASH_STREAM);
56
+ });
57
+
58
+ test("transcriptReadUrlFor: an explicit from offset appends as a query param", () => {
59
+ const url = new URL(transcriptReadUrlFor(ENDPOINT, "job:6494", 42));
60
+ assertEquals(url.searchParams.get("stream"), "job:6494");
61
+ assertEquals(url.searchParams.get("from"), "42");
62
+ });
63
+
64
+ test("#744 failure class: a gateway peel of one encoding layer breaks an encoded slash in a PATH segment but not a query value", () => {
65
+ // The legacy path form matches the `/app/api/agentic/transcripts/{stream}` route (5 segments)
66
+ // ONLY while the %2F stays encoded.
67
+ const pathForm = `${ENDPOINT}/${encodeURIComponent(SLASH_STREAM)}`;
68
+ assertEquals(pathSegments(pathForm), 5);
69
+ // The gateway peels exactly one percent-encoding layer before the app routes (#744 evidence):
70
+ // %2F becomes a real /, the app sees 6 segments, no route matches → 404 → the cockpit's replay
71
+ // fetch throws and the terminal region renders empty.
72
+ assertEquals(pathSegments(decodeURIComponent(pathForm)), 6);
73
+
74
+ // The query form is immune to the SAME peel: the pathname stays the collection route (4
75
+ // segments) before and after decoding, and the slash-bearing id round-trips intact.
76
+ const queryForm = transcriptReadUrlFor(ENDPOINT, SLASH_STREAM);
77
+ assertEquals(pathSegments(queryForm), 4);
78
+ const peeled = new URL(decodeURIComponent(queryForm));
79
+ assertEquals(pathSegments(peeled.href), 4);
80
+ assertEquals(peeled.searchParams.get("stream"), SLASH_STREAM);
81
+ });
@@ -48,7 +48,36 @@ export function transcriptUrlBaseFor(base: string = publicBaseUrl()): string {
48
48
  * The full durable transcript URL for a completed job's `jobKey` — the value a worker emits on
49
49
  * {@link TRANSCRIPT_URL_VAR}. Derived from {@link transcriptUrlBaseFor} + {@link jobStream} so it can
50
50
  * never disagree with the base the dispatcher seeds or the endpoint route it resolves to.
51
+ *
52
+ * The path-segment form is proxy-safe HERE because a `job:<jobKey>` id structurally never contains a
53
+ * slash (the jobKey is an engine key) — the #744 gateway-peel failure only bites slash-bearing ids.
54
+ * Any client that reads an ARBITRARY stream (e.g. the cockpit's past-session replay, whose
55
+ * worker-instance ids look like `34:<instance>/<jobKey>`) MUST use {@link transcriptReadUrlFor}
56
+ * instead.
51
57
  */
52
58
  export function transcriptUrlForJob(jobKey: string, base: string = publicBaseUrl()): string {
53
59
  return `${transcriptUrlBaseFor(base)}${jobStream(jobKey)}`;
54
60
  }
61
+
62
+ /**
63
+ * The proxy-safe single-stream READ URL (#744): `<transcriptsEndpoint>?stream=<id>[&from=<n>]` —
64
+ * the query form of `GET /agentic/transcripts` (operation `listAgenticTranscripts`), answering with
65
+ * the same bytes the legacy `GET /agentic/transcripts/{stream}` path form serves.
66
+ *
67
+ * The stream id rides a QUERY value, never a path segment: the Nano Console gateway proxy peels
68
+ * exactly one percent-encoding layer before the app routes, so an encoded slash (%2F) in a PATH
69
+ * segment arrives as a real / and splits a slash-bearing id (`34:<instance>/<jobKey>`) into an extra
70
+ * segment — no route matches and the read 404s. A / inside a query value is never a separator, so
71
+ * this form survives the peel intact (the whole failure class, not just one stream shape).
72
+ *
73
+ * `transcriptsEndpoint` is the collection-route URL (`<base>/app/api/agentic/transcripts` — the same
74
+ * string the cockpit carries as its `transcriptsUrl`). This is the SINGLE SOURCE OF TRUTH for the
75
+ * query form: the cockpit's browser adapter (`pages/cockpit/mount.js`, which cannot import server
76
+ * modules) carries a hand-maintained twin of this builder — keep the two in lockstep.
77
+ */
78
+ export function transcriptReadUrlFor(transcriptsEndpoint: string, stream: string, from?: number): string {
79
+ const url = new URL(transcriptsEndpoint);
80
+ url.searchParams.set("stream", stream);
81
+ if (from !== undefined) url.searchParams.set("from", String(from));
82
+ return url.href;
83
+ }
package/app/contracts.ts CHANGED
@@ -471,6 +471,14 @@ export const WIRE_CONTRACTS = {
471
471
  "The harness's job-end `phase:\"close\"` transcript `lifecycle` event (issue #710, harness half jwulf/c8ctl-plugin-nano#150) — the closing twin of the `phase:\"open\"` RELAY_OPEN_CHUNK the harness emits at relay-session open. The harness emits it on the `job:<jobKey>` relay stream through agentic's own `encodeTranscriptEvent` (never hand-rolled), right before `job.complete`/`job.fail` and AFTER draining its outbound relay buffer, so it is the deterministic \"all this job's bytes are here, it is done\" signal. The app recognizes it in `isTerminalLifecycleChunk` (relay.family.ts) to `completeStream()` — flush the durable past-session transcript and release job⇄instance correlation — at job-completion time, fixing the truncated-tail defect where the flush waited for a supersede/disconnect. `close` is NOT a core agentic `LifecycleEvent` phase (the contract's phases are open|completed|exited); the app decodes it via an ADDITIVE `mergeTranscriptVocab` extension (`RELAY_TERMINAL_VOCAB`) that maps it onto the terminal `completed` phase — never a forked wire shape, and scoped to the relay job-end detector so the cockpit derive (CORE vocab) keeps the close chunk byte-faithful. The `close` trigger is ADDITIVE: supersede + disconnect remain as fallbacks and the `state.completed` guard keeps a close-then-disconnect (or duplicate close) idempotent. Consume this ONE marker — do not re-declare a synonym or a second job-end signal.",
472
472
  shape: '{ nwfTranscriptEvent: 1, kind: "lifecycle", phase: "close" }',
473
473
  },
474
+ "transcript.readUrl": {
475
+ category: "wire",
476
+ name: "transcript.readUrl",
477
+ owner: "app/agentic/transcript-url.ts",
478
+ semantics:
479
+ "The proxy-safe single-stream transcript READ URL (issue #744): `GET <base>/app/api/agentic/transcripts?stream=<id>&from=<n>` — the stream id rides a QUERY value, NEVER a path segment, because the Nano Console gateway proxy peels one percent-encoding layer before the app routes: an encoded slash (%2F) in a PATH segment arrives as a real / and splits a slash-bearing worker-instance id (`34:<instance>/<jobKey>`) into an extra segment, so the legacy `GET /app/api/agentic/transcripts/{stream}` route 404s behind the proxy (the cockpit past-session replay rendered empty). The path form stays served for back-compat and is proxy-safe ONLY for the slash-free `job:<jobKey>` ids it is seeded with (the worker-emitted `transcriptUrl` = `transcriptUrlBaseFor()` + `jobStream()`, a bare concatenation, must remain resolvable both directly and behind the proxy). ONE builder: `transcriptReadUrlFor()` in app/agentic/transcript-url.ts, served by ONE canonical read (`readSingleTranscript` in app/agentic/transcript-read.ts) shared by both routes; the browser adapter pages/cockpit/mount.js carries a hand-maintained twin (it cannot import server modules). Never put a stream id in a path segment again — do not re-declare a synonym scheme.",
480
+ shape: "GET <transcriptsEndpoint>?stream=<percent-encoded stream id>[&from=<non-negative integer offset>] → AgenticTranscriptData | ErrorBody",
481
+ },
474
482
  } as const satisfies Record<string, WireContract>;
475
483
 
476
484
  export const TYPE_CONTRACTS = {
@@ -92,8 +92,14 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
92
92
  * client must parse). The deployed surface measures ~78,962 bytes (issue #715); this ceiling forbids
93
93
  * meaningful growth so a fat new schema cannot silently re-inflate the surface past the harness
94
94
  * deferral point.
95
+ *
96
+ * RAISE PROVENANCE — 84_000 → 84_500 (#744): the proxy-safe single-stream transcript read adds two
97
+ * input-schema properties (`stream`/`from`) to `listAgenticTranscripts` plus one summary line on
98
+ * each of the two transcript tools, keeping the `?stream=` form discoverable from the tool surface
99
+ * alone (measured +219 bytes serialized, 83,824 → 84,043 — over the old ceiling's 176-byte
100
+ * headroom). Deliberate, documented growth — not schema fat.
95
101
  */
96
- export const MCP_SURFACE_BYTES_BUDGET = 84_000;
102
+ export const MCP_SURFACE_BYTES_BUDGET = 84_500;
97
103
 
98
104
  /**
99
105
  * The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
package/openapi.yaml CHANGED
@@ -3795,11 +3795,33 @@ paths:
3795
3795
  operationId: listAgenticTranscripts
3796
3796
  summary: List captured agent sessions (H3/#146) — the durable transcripts an ephemeral agent flushed
3797
3797
  on job completion, readable AFTER it exited. Optional filters by jobKey / process instance / plan /
3798
- time. Advisory read-only; never gates control flow. Feeds the cockpit "past sessions" view.
3798
+ time. Also serves the proxy-safe single-stream READ form `?stream=` (#744). Advisory read-only;
3799
+ never gates control flow. Feeds the cockpit "past sessions" view.
3799
3800
  security:
3800
3801
  - hookSecret: []
3801
3802
  - {}
3802
3803
  parameters:
3804
+ - name: stream
3805
+ in: query
3806
+ required: false
3807
+ schema:
3808
+ type: string
3809
+ description: >-
3810
+ Single-stream READ form (#744, proxy-safe): when present, the response is that ONE
3811
+ stream's transcript bytes (`AgenticTranscriptData` — the payload
3812
+ `GET /agentic/transcripts/{stream}` serves), NOT the list, and the filters below are
3813
+ ignored. The id rides a query value, so slash-bearing worker-instance ids
3814
+ (`34:<instance>/<jobKey>`) survive gateways that decode %2F in path segments before
3815
+ routing. Prefer this form over the path form behind any proxy.
3816
+ - name: from
3817
+ in: query
3818
+ required: false
3819
+ schema:
3820
+ type: integer
3821
+ minimum: 0
3822
+ default: 0
3823
+ description: Only with `stream` — resume from this offset (inclusive). Default 0. Rejected with 400
3824
+ when supplied without `stream`.
3803
3825
  - name: jobKey
3804
3826
  in: query
3805
3827
  required: false
@@ -3846,13 +3868,17 @@ paths:
3846
3868
  description: Return only sessions created at or before this ISO-8601 instant.
3847
3869
  responses:
3848
3870
  "200":
3849
- description: The captured session list.
3871
+ description: The captured session list — or, in the single-stream form (`?stream=`), that
3872
+ stream's stored transcript bytes (#744).
3850
3873
  content:
3851
3874
  application/json:
3852
3875
  schema:
3853
- $ref: "#/components/schemas/AgenticTranscriptList"
3876
+ oneOf:
3877
+ - $ref: "#/components/schemas/AgenticTranscriptList"
3878
+ - $ref: "#/components/schemas/AgenticTranscriptData"
3854
3879
  "400":
3855
- description: A malformed filter (e.g. an unparseable since/until).
3880
+ description: A malformed filter (e.g. an unparseable since/until), or — in the single-stream
3881
+ form — a malformed `from` offset.
3856
3882
  content:
3857
3883
  application/json:
3858
3884
  schema:
@@ -3863,12 +3889,19 @@ paths:
3863
3889
  application/json:
3864
3890
  schema:
3865
3891
  $ref: "#/components/schemas/ErrorBody"
3892
+ "404":
3893
+ description: Single-stream form only — no transcript exists for the given `stream`.
3894
+ content:
3895
+ application/json:
3896
+ schema:
3897
+ $ref: "#/components/schemas/ErrorBody"
3866
3898
  /agentic/transcripts/{stream}:
3867
3899
  get:
3868
3900
  operationId: getAgenticTranscript
3869
3901
  summary: Fetch a stored transcript's bytes (H3/#146), range/offset-based so the cockpit terminal
3870
- replays it through the same resume-from-offset renderer it uses for a live stream. Advisory
3871
- read-only; never gates control flow.
3902
+ replays it through the same resume-from-offset renderer it uses for a live stream. PATH form
3903
+ of the single-stream read (back-compat; a slash-bearing id 404s behind a decoding proxy —
3904
+ prefer the `?stream=` query form there, #744). Advisory read-only; never gates control flow.
3872
3905
  security:
3873
3906
  - hookSecret: []
3874
3907
  - {}
@@ -3878,7 +3911,9 @@ paths:
3878
3911
  required: true
3879
3912
  schema:
3880
3913
  type: string
3881
- description: The relay stream id to fetch (`job:<jobKey>` for a job stream).
3914
+ description: The relay stream id to fetch (`job:<jobKey>` for a job stream). CAVEAT (#744) —
3915
+ a gateway that decodes %2F in a path segment splits a slash-bearing id and this route
3916
+ 404s; such ids must use the `?stream=` query form. Slash-free ids are safe in either.
3882
3917
  - name: from
3883
3918
  in: query
3884
3919
  required: false
@@ -2,8 +2,12 @@
2
2
  //
3
3
  // Fetch a stored transcript's bytes, range/offset-based (?from=<offset>, default 0) so the cockpit
4
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`.
5
+ // (static playback of an exited agent). The PATH form of the single-stream read, kept for back-compat
6
+ // (the worker-emitted `transcriptUrl` and Explorer links resolve here — safe because `job:<jobKey>`
7
+ // ids never contain a slash); proxy-exposed clients use the `?stream=` QUERY form on the collection
8
+ // route instead (#744 — a gateway that decodes %2F in a path segment 404s this route). Both forms run
9
+ // the ONE canonical read (`readSingleTranscript` in app/agentic/transcript-read.ts), so they can never
10
+ // answer differently for the same stream/from.
7
11
  //
8
12
  // Advisory read-only (ADR 0056): it NEVER gates a BPMN sequence flow. Unknown stream -> 404; a
9
13
  // malformed `from` -> 400. Shared-secret guard mirrors getAgenticSupply (x-hook-secret when
@@ -11,7 +15,7 @@
11
15
 
12
16
  import { currentCorrelation } from "../app/agentic/correlation.ts";
13
17
  import { currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
14
- import { readTranscriptFrom } from "../app/agentic/transcript-read.ts";
18
+ import { readSingleTranscript } from "../app/agentic/transcript-read.ts";
15
19
  import { envVar } from "../app/version.ts";
16
20
  import { defineOperation } from "../nano-generated/operations.ts";
17
21
 
@@ -23,29 +27,5 @@ export default defineOperation("getAgenticTranscript", async ({ params, query, r
23
27
  return { status: 401, body: { error: "unauthorized" } };
24
28
  }
25
29
 
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 service = currentRelayTranscriptService();
32
- if (!service) {
33
- // No relay/transcript service mounted at all - nothing to replay.
34
- return { status: 404, body: { error: "no transcript for stream" } };
35
- }
36
-
37
- // Read the durable store first, falling back to the still-live relay ring (#486) so a `transcriptUrl`
38
- // emitted by a job on a still-live multiplexing worker is readable before its ring is flushed.
39
- const data = readTranscriptFrom(
40
- params.stream,
41
- from,
42
- service.store,
43
- currentCorrelation(),
44
- service.correlationStore,
45
- service.liveFallback(params.stream),
46
- );
47
- if (data === undefined) {
48
- return { status: 404, body: { error: "no transcript for stream" } };
49
- }
50
- return { status: 200, body: data };
30
+ return readSingleTranscript(params.stream, query.from, currentRelayTranscriptService(), currentCorrelation());
51
31
  });
@@ -167,3 +167,89 @@ test("shared-secret guard rejects a missing secret when configured", async () =>
167
167
  else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
168
168
  }
169
169
  });
170
+
171
+ // #744 — the proxy-safe single-stream read form: `?stream=` on the collection route returns that
172
+ // transcript's BYTES (the AgenticTranscriptData payload GET /{stream} serves), NOT the session
173
+ // list. Worker-instance stream ids contain a slash (`34:<instance>/<jobKey>`); behind the console
174
+ // gateway — which decodes %2F in a path segment back to a real / before the app routes — the
175
+ // legacy path form 404s, so the cockpit reads a single stream through this query form.
176
+ test("#744: ?stream= returns the single transcript's bytes for a slash-bearing id (not the list)", async () => {
177
+ relayFamily.mount(mountCtx(memSqlite()));
178
+ const store = currentRelayTranscriptService()?.store;
179
+ assert(store !== undefined);
180
+ const stream = "34:joshs-macbook-pro-copilot-3d6ee882/13859";
181
+ store.flush(
182
+ stream,
183
+ { since: () => ({ entries: [{ offset: 0, chunk: "aa" }, { offset: 1, chunk: "bb" }] }), nextOffset: 2 },
184
+ "ephemeral",
185
+ );
186
+ try {
187
+ const res = (await handler(input({ stream }), app)) as {
188
+ status: number;
189
+ body: {
190
+ stream: string;
191
+ from: number;
192
+ gap: boolean;
193
+ nextOffset: number;
194
+ chunkCount: number;
195
+ byteLength: number;
196
+ entries: Array<{ offset: number; chunk: string }>;
197
+ };
198
+ };
199
+ assertEquals(res.status, 200);
200
+ assertEquals(res.body.stream, stream);
201
+ assertEquals(res.body.from, 0);
202
+ assertEquals(res.body.gap, false);
203
+ assertEquals(res.body.nextOffset, 2);
204
+ assertEquals(res.body.chunkCount, 2);
205
+ assertEquals(res.body.byteLength, 4);
206
+ assertEquals(res.body.entries.map((e) => e.chunk), ["aa", "bb"]);
207
+ } finally {
208
+ relayFamily.teardown?.();
209
+ }
210
+ });
211
+
212
+ test("#744: ?stream= honors from, 404s an unknown stream, and 400s a malformed from", async () => {
213
+ relayFamily.mount(mountCtx(memSqlite()));
214
+ const store = currentRelayTranscriptService()?.store;
215
+ assert(store !== undefined);
216
+ store.flush(
217
+ "job:6494",
218
+ { since: () => ({ entries: [{ offset: 0, chunk: "aa" }, { offset: 1, chunk: "bb" }] }), nextOffset: 2 },
219
+ "ephemeral",
220
+ );
221
+ try {
222
+ const resume = (await handler(input({ stream: "job:6494", from: 1 }), app)) as {
223
+ status: number;
224
+ body: { from: number; chunkCount: number; entries: Array<{ chunk: string }> };
225
+ };
226
+ assertEquals(resume.status, 200);
227
+ assertEquals(resume.body.from, 1);
228
+ assertEquals(resume.body.entries.map((e) => e.chunk), ["bb"]);
229
+
230
+ const unknown = (await handler(input({ stream: "job:nope" }), app)) as { status: number };
231
+ assertEquals(unknown.status, 404);
232
+
233
+ const bad = (await handler(input({ stream: "job:6494", from: -1 }), app)) as { status: number };
234
+ assertEquals(bad.status, 400);
235
+ } finally {
236
+ relayFamily.teardown?.();
237
+ }
238
+ });
239
+
240
+ test("#744: ?stream= 404s (not an empty list) when no relay/transcript family is mounted", async () => {
241
+ relayFamily.teardown?.();
242
+ const res = (await handler(input({ stream: "job:1" }), app)) as { status: number };
243
+ assertEquals(res.status, 404);
244
+ });
245
+
246
+ test("#744: `from` without `stream` is a 400 (not a silently-ignored list read)", async () => {
247
+ relayFamily.mount(mountCtx(memSqlite()));
248
+ try {
249
+ const res = (await handler(input({ from: 0 }), app)) as { status: number; body: { error: string } };
250
+ assertEquals(res.status, 400);
251
+ assert(res.body.error.includes("stream"));
252
+ } finally {
253
+ relayFamily.teardown?.();
254
+ }
255
+ });
@@ -6,13 +6,19 @@
6
6
  // correlated via `app/agentic/correlation.ts` (best-effort — jobKey is always recovered from the stream
7
7
  // id, engine context only while the job is still live). Feeds the cockpit "past sessions" view.
8
8
  //
9
+ // Also serves the proxy-safe single-stream READ form (#744): `?stream=<id>&from=<n>` switches this route
10
+ // from the session list to that ONE stream's bytes — the same payload GET /{stream} serves, through the
11
+ // same canonical read (`readSingleTranscript`). The id rides a QUERY value so a slash-bearing stream id
12
+ // (`34:<instance>/<jobKey>`) survives gateway proxies that decode %2F in a PATH segment back to a real /
13
+ // before routing (the cockpit replay 404'd silently behind the Nano Console).
14
+ //
9
15
  // Advisory read-only (ADR 0056): it NEVER gates a BPMN sequence flow. Optional filters (jobKey / process
10
16
  // instance / plan / time) narrow the feed. The optional shared-secret guard mirrors getAgenticSupply:
11
17
  // when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header; unset -> open.
12
18
 
13
19
  import { currentCorrelation } from "../app/agentic/correlation.ts";
14
20
  import { currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
15
- import { listTranscripts, type TranscriptFilter } from "../app/agentic/transcript-read.ts";
21
+ import { listTranscripts, readSingleTranscript, type TranscriptFilter } from "../app/agentic/transcript-read.ts";
16
22
  import { envVar } from "../app/version.ts";
17
23
  import type { AgenticTranscriptList } from "../nano-generated/api-io.d.ts";
18
24
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -30,6 +36,18 @@ export default defineOperation("listAgenticTranscripts", async ({ query, req },
30
36
  return { status: 401, body: { error: "unauthorized" } };
31
37
  }
32
38
 
39
+ // #744: the single-stream READ form takes precedence — `?stream=` addresses ONE transcript's bytes,
40
+ // not the list, so the list filters below do not apply.
41
+ if (query.stream !== undefined) {
42
+ return readSingleTranscript(query.stream, query.from, currentRelayTranscriptService(), currentCorrelation());
43
+ }
44
+
45
+ // `from` addresses an offset WITHIN one stream, so it is only meaningful with `?stream=`. Reject it
46
+ // rather than silently returning the list, so the API can never quietly ignore a caller's intent.
47
+ if (query.from !== undefined) {
48
+ return { status: 400, body: { error: "invalid from: only valid together with stream" } };
49
+ }
50
+
33
51
  if (badInstant(query.since) || badInstant(query.until)) {
34
52
  return { status: 400, body: { error: "invalid since/until: expected an ISO-8601 instant" } };
35
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.182.1",
3
+ "version": "0.182.3",
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",
@@ -64,7 +64,7 @@
64
64
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
65
65
  },
66
66
  "dependencies": {
67
- "@nanobpm/agentic": "^0.12.0",
67
+ "@nanobpm/agentic": "^0.13.0",
68
68
  "@nanobpm/urban": "^0.92.0",
69
69
  "bpmn-auto-layout": "^2.0.0-alpha.2"
70
70
  },
@@ -468,7 +468,9 @@ function relaySocketFactory(url) {
468
468
  * @param {string} [opts.transcriptsUrl] — the captured-session list endpoint backing the always-on
469
469
  * "past sessions" history + replay (default
470
470
  * `new URL("../app/api/agentic/transcripts", import.meta.url).href`, module-anchored so it
471
- * resolves to the app root `<appMount>/app/api/agentic/transcripts`, not the `/cockpit/` shell base).
471
+ * resolves to the app root `<appMount>/app/api/agentic/transcripts`, not the `/cockpit/` shell
472
+ * base). The per-session replay read uses the proxy-safe `?stream=` query form on this same URL
473
+ * (#744 — never a `/…/<id>` path segment, which a decoding gateway splits on encoded slashes).
472
474
  * @returns a handle with `.dispose()`.
473
475
  */
474
476
  export function mountCockpit(host, opts = {}) {
@@ -718,7 +720,8 @@ export function mountCockpit(host, opts = {}) {
718
720
  abortTimer.unref?.();
719
721
  let res;
720
722
  try {
721
- res = await fetch(`${transcriptsUrl}/${encodeURIComponent(stream)}`, { headers: jsonHeaders(), signal: controller.signal });
723
+ // Proxy-safe read URL (#744): the stream id rides the QUERY (?stream=), never a path segment.
724
+ res = await fetch(transcriptReadUrl(stream), { headers: jsonHeaders(), signal: controller.signal });
722
725
  } finally {
723
726
  clearTimeout(abortTimer);
724
727
  }
@@ -753,6 +756,25 @@ export function mountCockpit(host, opts = {}) {
753
756
  return url.href;
754
757
  }
755
758
 
759
+ // The proxy-safe single-stream READ URL (#744) — the browser twin of `transcriptReadUrlFor` in
760
+ // app/agentic/transcript-url.ts (mount.js cannot import the server module; keep the two in
761
+ // lockstep). The stream id rides a QUERY value, never a path segment: the console gateway peels
762
+ // one percent-encoding layer before the app routes, so an encoded slash (%2F) in a PATH segment
763
+ // arrives as a real / and splits a slash-bearing worker-instance id (`34:<instance>/<jobKey>`)
764
+ // into an extra segment — the app matches no route and answers 404, which left the past-session
765
+ // replay silently empty behind the proxy. A / inside a query value is never a separator.
766
+ //
767
+ // Mirrors the server SSOT's optional `from` offset (`transcriptReadUrlFor(endpoint, stream, from?)`):
768
+ // omitted -> no `from` param (read from the start); a numeric offset appends `&from=<n>` so a
769
+ // resume-from-offset replay can fetch from a non-zero position and the two twins stay structurally
770
+ // in lockstep.
771
+ function transcriptReadUrl(stream, from) {
772
+ const url = new URL(transcriptsUrl, location.href);
773
+ url.searchParams.set("stream", stream);
774
+ if (from !== undefined) url.searchParams.set("from", String(from));
775
+ return url.href;
776
+ }
777
+
756
778
  async function refreshPast(instance = routeInstance()) {
757
779
  // Single-flight: while one past-fetch is outstanding (including a hung one), skip starting another
758
780
  // so the supply poll can't stack pending fetches against a slow/unresponsive transcripts endpoint.