@nanobpm/nano-workforce 0.182.2 → 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 +6 -0
- package/app/agentic/cockpit/mount.test.ts +55 -2
- package/app/agentic/cockpit/supply-boot.ts +5 -1
- package/app/agentic/transcript-read.ts +46 -1
- package/app/agentic/transcript-url.test.ts +46 -0
- package/app/agentic/transcript-url.ts +29 -0
- package/app/contracts.ts +8 -0
- package/app/mcpToolSurface.ts +7 -1
- package/openapi.yaml +42 -7
- package/operations/getAgenticTranscript.ts +8 -28
- package/operations/listAgenticTranscripts.test.ts +86 -0
- package/operations/listAgenticTranscripts.ts +19 -1
- package/package.json +1 -1
- package/pages/cockpit/mount.js +24 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.182.2](https://github.com/nanobpm/nano-workforce/compare/v0.182.1...v0.182.2) (2026-09-04)
|
|
2
8
|
|
|
3
9
|
### 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 &&
|
|
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
|
|
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 = {
|
package/app/mcpToolSurface.ts
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
-
|
|
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.
|
|
3871
|
-
read-
|
|
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).
|
|
6
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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",
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|