@nanobpm/nano-workforce 0.182.2 → 0.183.0

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.183.0](https://github.com/nanobpm/nano-workforce/compare/v0.182.3...v0.183.0) (2026-09-07)
2
+
3
+ ### Features
4
+
5
+ * **agent:** mark senior:* tasks as engine-native external AgentTasks ([#748](https://github.com/nanobpm/nano-workforce/issues/748)) ([806eea5](https://github.com/nanobpm/nano-workforce/commit/806eea522490a999747ad4814fc92847dab3a6e4)), closes [#464](https://github.com/nanobpm/nano-workforce/issues/464) [#745](https://github.com/nanobpm/nano-workforce/issues/745) [#746](https://github.com/nanobpm/nano-workforce/issues/746) [#747](https://github.com/nanobpm/nano-workforce/issues/747) [Magikcraft/nano-bpm#1137](https://github.com/Magikcraft/nano-bpm/issues/1137)
6
+
7
+ ## [0.182.3](https://github.com/nanobpm/nano-workforce/compare/v0.182.2...v0.182.3) (2026-09-06)
8
+
9
+ ### Bug Fixes
10
+
11
+ * 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)
12
+
1
13
  ## [0.182.2](https://github.com/nanobpm/nano-workforce/compare/v0.182.1...v0.182.2) (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
+ }
@@ -0,0 +1,74 @@
1
+ // Tests for the engine-native AgentTask marker (issue #745, umbrella #746 — Camunda 8.10 parity),
2
+ // including the defect-class regression guard: every deployed prompt-bearing `senior:*` agent task
3
+ // must carry `<zeebe:agentDefinition agentType="external" />` alongside its `<zeebe:taskDefinition>`,
4
+ // so the worker harness mints an engine-native AgentInstance for it. A newly-added agent task that
5
+ // forgets the marker never persists durable AgentHistory — so it fails CI here instead of silently
6
+ // drifting.
7
+ import { readFileSync, readdirSync } from "node:fs";
8
+ import { test } from "node:test";
9
+ import { dirname, join, relative } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import { agentTaskTypesMissingExternalMarker, promptBearingTaskTypes } from "./job-types.ts";
13
+
14
+ const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
15
+
16
+ // urban deploys `resources/` recursively (every file at any depth), so a process
17
+ // model added under a subdirectory would still deploy — walk recursively here too,
18
+ // or the guard would miss it and let an unmarked agent task slip through.
19
+ function bpmnFiles(): string[] {
20
+ const walk = (dir: string): string[] =>
21
+ readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
22
+ const full = join(dir, entry.name);
23
+ if (entry.isDirectory()) return walk(full);
24
+ return entry.name.endsWith(".bpmn") ? [relative(PROCESSES_DIR, full)] : [];
25
+ });
26
+ return walk(PROCESSES_DIR).sort();
27
+ }
28
+
29
+ test("agentTaskTypesMissingExternalMarker flags a prompt-bearing agent task with no external marker", () => {
30
+ const xml = `
31
+ <bpmn:serviceTask id="agent">
32
+ <bpmn:extensionElements>
33
+ <zeebe:taskDefinition type="senior:feature" />
34
+ <zeebe:linkedResources>
35
+ <zeebe:linkedResource resourceId="feature.md" resourceType="GenericScript" linkName="prompt" />
36
+ </zeebe:linkedResources>
37
+ </bpmn:extensionElements>
38
+ </bpmn:serviceTask>`;
39
+ assertEquals(agentTaskTypesMissingExternalMarker(xml), ["senior:feature"]);
40
+ });
41
+
42
+ test("agentTaskTypesMissingExternalMarker passes a marked agent task and ignores a plain host task", () => {
43
+ const xml = `
44
+ <bpmn:serviceTask id="host">
45
+ <bpmn:extensionElements>
46
+ <zeebe:taskDefinition type="pr.finalize" />
47
+ </bpmn:extensionElements>
48
+ </bpmn:serviceTask>
49
+ <bpmn:serviceTask id="agent">
50
+ <bpmn:extensionElements>
51
+ <zeebe:taskDefinition type="senior:feature" />
52
+ <zeebe:agentDefinition agentType="external" />
53
+ <zeebe:linkedResources>
54
+ <zeebe:linkedResource resourceId="feature.md" resourceType="GenericScript" linkName="prompt" />
55
+ </zeebe:linkedResources>
56
+ </bpmn:extensionElements>
57
+ </bpmn:serviceTask>`;
58
+ assertEquals(agentTaskTypesMissingExternalMarker(xml), []);
59
+ });
60
+
61
+ test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent task carries the external AgentTask marker", () => {
62
+ let anyAgentTasks = false;
63
+ for (const file of bpmnFiles()) {
64
+ const xml = readFileSync(join(PROCESSES_DIR, file), "utf8");
65
+ if (promptBearingTaskTypes(xml).length > 0) anyAgentTasks = true;
66
+ const missing = agentTaskTypesMissingExternalMarker(xml);
67
+ assert(
68
+ missing.length === 0,
69
+ `${file}: agent task(s) missing <zeebe:agentDefinition agentType="external" /> — ${missing.join(", ")}`,
70
+ );
71
+ }
72
+ // Sanity: the models really do declare agent tasks (guard is not vacuously green).
73
+ assert(anyAgentTasks, "the deployed models declare prompt-bearing agent tasks");
74
+ });
@@ -49,6 +49,10 @@ export function jobTypeToRoutingToken(jobType: string): string | undefined {
49
49
  const SERVICE_TASK = /<(?:\w+:)?serviceTask\b[\s\S]*?<\/(?:\w+:)?serviceTask>/g;
50
50
  const TASK_DEFINITION_TYPE = /<(?:\w+:)?taskDefinition\b[^>]*\btype="([^"]*)"/;
51
51
  const PROMPT_LINK = /<(?:\w+:)?linkedResource\b[^>]*\blinkName="prompt"/;
52
+ // The engine-native AgentTask marker (issue #745): a `<zeebe:agentDefinition agentType="external" />`
53
+ // sibling of the `<zeebe:taskDefinition>` inside a `senior:*` agent task's extensionElements. It is
54
+ // what makes the element eligible for engine-native AgentInstance minting by the worker harness.
55
+ const EXTERNAL_AGENT_MARKER = /<(?:\w+:)?agentDefinition\b[^>]*\bagentType="external"/;
52
56
 
53
57
  /**
54
58
  * Scan one BPMN document for the job types of its PROMPT-BEARING service tasks — the deployed fleet
@@ -68,3 +72,25 @@ export function promptBearingTaskTypes(xml: string): string[] {
68
72
  }
69
73
  return types;
70
74
  }
75
+
76
+ /**
77
+ * Scan one BPMN document for the job types of PROMPT-BEARING agent service tasks that are MISSING the
78
+ * engine-native AgentTask marker `<zeebe:agentDefinition agentType="external" />` (issue #745). Every
79
+ * deployed `senior:*` agent task must carry the marker so the worker harness mints an AgentInstance
80
+ * for it; a newly-added agent task that forgets it is a silent drift surface (its run never persists
81
+ * durable AgentHistory), so the regression guard fails CI. Returns the offending task types in
82
+ * first-occurrence order (empty when every agent task is marked).
83
+ */
84
+ export function agentTaskTypesMissingExternalMarker(xml: string): string[] {
85
+ const seen = new Set<string>();
86
+ const missing: string[] = [];
87
+ for (const [block] of xml.matchAll(SERVICE_TASK)) {
88
+ if (!PROMPT_LINK.test(block)) continue;
89
+ if (EXTERNAL_AGENT_MARKER.test(block)) continue;
90
+ const type = block.match(TASK_DEFINITION_TYPE)?.[1];
91
+ if (type === undefined || type.length === 0 || seen.has(type)) continue;
92
+ seen.add(type);
93
+ missing.push(type);
94
+ }
95
+ return missing;
96
+ }
package/app/contracts.ts CHANGED
@@ -471,6 +471,22 @@ 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
+ },
482
+ "agentTask.agentDefinition": {
483
+ category: "wire",
484
+ name: "agentTask.agentDefinition",
485
+ owner: "resources/processes/*.bpmn",
486
+ semantics:
487
+ "The engine-native AgentTask marker (issue #745, umbrella #746 — Camunda 8.10 parity). Every `senior:*` agent service task carries `<zeebe:agentDefinition agentType=\"external\" />` INSIDE its `<bpmn:extensionElements>`, COEXISTING with the existing `<zeebe:taskDefinition type=\"senior:*\"/>` dispatch verb (the verb stays, per #464). The marker makes the element eligible for engine-native AgentInstance minting by the worker harness (jwulf/c8ctl-plugin-nano#194): the harness mints Create/Update/Complete AgentInstance/AgentHistory records against the pinned engine (`@nanobpm/engine-wasm` 0.8.6, broker REST, SDK) while the element still emits its NORMAL `senior:*` job. `agentType=\"external\"` means the agent runs OUTSIDE the engine (a remote fleet worker), not an engine-embedded model call. This is the PRODUCER half; the durable AgentInstance/AgentHistory it mints is read back by the Cockpit historical view via `searchAgentInstanceHistory` (the CONSUMER half, deferred until the broker read API is reachable from the app's EngineClient). It is authored in the hand-written BPMN semantic model, NOT the generated `<bpmndi:…>` DI, and survives `npm run layout` untouched. Add the marker to a NEW `senior:*` agent task — never a second/synonym marker element.",
488
+ shape: '<zeebe:agentDefinition agentType="external" /> (sibling of <zeebe:taskDefinition> in a senior:* service task\'s extensionElements)',
489
+ },
474
490
  } as const satisfies Record<string, WireContract>;
475
491
 
476
492
  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.2",
3
+ "version": "0.183.0",
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",
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "@biomejs/biome": "^2.4.11",
73
- "@nanobpm/urban-testkit": "^1.2.0",
73
+ "@nanobpm/urban-testkit": "^1.3.0",
74
74
  "@nanobpm/workflow": "^0.14.0",
75
75
  "@semantic-release/changelog": "^7.0.0",
76
76
  "@semantic-release/git": "^11.0.0",
@@ -83,6 +83,7 @@
83
83
  "yaml": "^2.9.0"
84
84
  },
85
85
  "overrides": {
86
+ "@nanobpm/engine-wasm": "^0.9.0",
86
87
  "@semantic-release/npm": "^13.1.5"
87
88
  }
88
89
  }
@@ -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.
@@ -100,6 +100,7 @@
100
100
  <bpmn:serviceTask id="review-round" name="Review round (agent)">
101
101
  <bpmn:extensionElements>
102
102
  <zeebe:taskDefinition type="senior:pr-review" />
103
+ <zeebe:agentDefinition agentType="external" />
103
104
  <zeebe:linkedResources>
104
105
  <zeebe:linkedResource resourceId="prompts/review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
105
106
  </zeebe:linkedResources>
@@ -320,6 +321,7 @@
320
321
  <bpmn:serviceTask id="classify-scope" name="Scope classifier (agent)">
321
322
  <bpmn:extensionElements>
322
323
  <zeebe:taskDefinition type="senior:scope-classify" />
324
+ <zeebe:agentDefinition agentType="external" />
323
325
  <zeebe:linkedResources>
324
326
  <zeebe:linkedResource resourceId="prompts/scope-classify.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
325
327
  </zeebe:linkedResources>
@@ -16,6 +16,7 @@
16
16
  <bpmn:serviceTask id="implement-task" name="Implement (agent)">
17
17
  <bpmn:extensionElements>
18
18
  <zeebe:taskDefinition type="senior:feature" />
19
+ <zeebe:agentDefinition agentType="external" />
19
20
  <zeebe:linkedResources>
20
21
  <zeebe:linkedResource resourceId="prompts/feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
21
22
  </zeebe:linkedResources>
@@ -7,6 +7,7 @@
7
7
  <bpmn:serviceTask id="trial-merge" name="Trial merge (agent)">
8
8
  <bpmn:extensionElements>
9
9
  <zeebe:taskDefinition type="senior:trial-merge" />
10
+ <zeebe:agentDefinition agentType="external" />
10
11
  <zeebe:linkedResources>
11
12
  <zeebe:linkedResource resourceId="prompts/trial-merge.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
12
13
  </zeebe:linkedResources>
@@ -339,6 +339,7 @@
339
339
  <bpmn:serviceTask id="fix-ci" name="Fix CI (agent)">
340
340
  <bpmn:extensionElements>
341
341
  <zeebe:taskDefinition type="senior:fix-ci" />
342
+ <zeebe:agentDefinition agentType="external" />
342
343
  <zeebe:linkedResources>
343
344
  <zeebe:linkedResource resourceId="prompts/fix-ci.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
344
345
  </zeebe:linkedResources>
@@ -496,6 +497,7 @@
496
497
  <bpmn:serviceTask id="rebase" name="Rebase (agent)">
497
498
  <bpmn:extensionElements>
498
499
  <zeebe:taskDefinition type="senior:rebase" />
500
+ <zeebe:agentDefinition agentType="external" />
499
501
  <zeebe:linkedResources>
500
502
  <zeebe:linkedResource resourceId="prompts/rebase.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
501
503
  </zeebe:linkedResources>
@@ -260,6 +260,7 @@
260
260
  <bpmn:serviceTask id="plan" name="Plan (agent)">
261
261
  <bpmn:extensionElements>
262
262
  <zeebe:taskDefinition type="senior:plan" />
263
+ <zeebe:agentDefinition agentType="external" />
263
264
  <zeebe:linkedResources>
264
265
  <zeebe:linkedResource resourceId="prompts/plan.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
265
266
  </zeebe:linkedResources>
@@ -311,6 +312,7 @@
311
312
  <bpmn:serviceTask id="review-plan" name="Review plan (agent)">
312
313
  <bpmn:extensionElements>
313
314
  <zeebe:taskDefinition type="senior:plan-review" />
315
+ <zeebe:agentDefinition agentType="external" />
314
316
  <zeebe:linkedResources>
315
317
  <zeebe:linkedResource resourceId="prompts/plan-review.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
316
318
  </zeebe:linkedResources>
@@ -520,6 +522,7 @@
520
522
  <bpmn:serviceTask id="trial-merge" name="Trial merge (agent)">
521
523
  <bpmn:extensionElements>
522
524
  <zeebe:taskDefinition type="senior:trial-merge" />
525
+ <zeebe:agentDefinition agentType="external" />
523
526
  <zeebe:linkedResources>
524
527
  <zeebe:linkedResource resourceId="prompts/trial-merge.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
525
528
  </zeebe:linkedResources>
@@ -43,6 +43,7 @@
43
43
  <bpmn:serviceTask id="conformance" name="Verify implementation vs spec (agent)">
44
44
  <bpmn:extensionElements>
45
45
  <zeebe:taskDefinition type="senior:conformance" />
46
+ <zeebe:agentDefinition agentType="external" />
46
47
  <zeebe:linkedResources>
47
48
  <zeebe:linkedResource resourceId="prompts/conformance.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
48
49
  </zeebe:linkedResources>
@@ -91,6 +92,7 @@
91
92
  <bpmn:serviceTask id="synthesize" name="Synthesize &#38; promote (agent)">
92
93
  <bpmn:extensionElements>
93
94
  <zeebe:taskDefinition type="senior:retro" />
95
+ <zeebe:agentDefinition agentType="external" />
94
96
  <zeebe:linkedResources>
95
97
  <zeebe:linkedResource resourceId="prompts/retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
96
98
  </zeebe:linkedResources>
@@ -9,18 +9,19 @@
9
9
  // and linked resources — with a legible red/green diff on mismatch.
10
10
  //
11
11
  // Ported models run a real `assertDerivationParity`; parked models (see
12
- // `./flows.ts`) are reported as skipped WITH their precise reason, in two
12
+ // `./flows.ts`) are reported as skipped WITH their precise reason, in three
13
13
  // blocker classes — class 1: multiple top-level start/end events; class 2:
14
- // arbitrary control-flow graph (`convergence-loop`). Companion diagnostics prove
15
- // each blocker is real against the goldens themselves. No golden is modified to
14
+ // arbitrary control-flow graph (`convergence-loop`); class 3: the engine-native
15
+ // agent-task marker (`retro`, issue #745). Companion diagnostics prove each
16
+ // blocker is real against the goldens themselves. No golden is modified to
16
17
  // force a match — the derivation must reproduce the checked-in file.
17
18
 
18
19
  import { test } from "node:test";
19
20
  import { readFileSync } from "node:fs";
20
21
  import { assert, assertEquals } from "#test-assert";
21
- import { assertDerivationParity, normalize } from "@nanobpm/workflow/test-support";
22
+ import { assertDerivationParity, diffModels, modelsEqual, normalize } from "@nanobpm/workflow/test-support";
22
23
  import { declarativeToBpmn, defineFlow } from "@nanobpm/workflow";
23
- import { PORTS } from "./flows.ts";
24
+ import { PORTS, retroFlow } from "./flows.ts";
24
25
 
25
26
  const ROOT = decodeURIComponent(new URL("../../", import.meta.url).pathname);
26
27
  const goldenPath = (model: string): string => `${ROOT}resources/processes/${model}.bpmn`;
@@ -98,8 +99,9 @@ test("class-1 blocked goldens genuinely have multiple top-level start/end events
98
99
  }
99
100
 
100
101
  // The two single-start/single-end goldens (retro, convergence-loop) clear
101
- // class 1; retro is a GREEN whole-model parity port (it runs through
102
- // `assertDerivationParity` above), convergence-loop is class-2 blocked (below).
102
+ // class 1; each is blocked on a LATER class instead retro on the class-3
103
+ // agent-task marker (proven below), convergence-loop on its class-2 arbitrary
104
+ // graph.
103
105
  for (const model of ["retro", "convergence-loop"]) {
104
106
  const xml = readFileSync(goldenPath(model), "utf8");
105
107
  assertEquals(countTag(xml, "startEvent"), 1, `${model} should have one start event`);
@@ -157,3 +159,38 @@ test("loop() inserts a gateway head, so back-edges cannot merge into a task", ()
157
159
  "the loop-body task cannot itself be the back-edge merge (it stays in<=1)",
158
160
  );
159
161
  });
162
+
163
+ // CLASS 3 — retro has a single top-level start/end (clears class 1) and a fully
164
+ // structured topology (clears class 2), but issue #745 added the engine-native
165
+ // AgentTask marker `<zeebe:agentDefinition agentType="external" />` to its two
166
+ // prompt-bearing `senior:*` tasks and the published compiler cannot emit it.
167
+ // Prove the blocker is real AND that it is the ONLY divergence, so the parked
168
+ // entry is a complete, verified port held ready — not an abandoned one.
169
+ test("retro's complete port differs from its golden by ONLY the agent-task marker", () => {
170
+ const golden = readFileSync(goldenPath("retro"), "utf8");
171
+ const derived = declarativeToBpmn(retroFlow);
172
+ const markers = (xml: string): number =>
173
+ (xml.match(/<(?:\w+:)?agentDefinition\b[^>]*\bagentType="external"/g) ?? []).length;
174
+
175
+ // (a) the golden really carries the marker, on BOTH of its agent tasks — and it
176
+ // cannot simply be dropped: app/agentic/vocab/agent-marker.test.ts is a
177
+ // defect-class guard requiring it on every deployed prompt-bearing agent task.
178
+ assertEquals(markers(golden), 2, "retro's golden should mark senior:conformance and senior:retro");
179
+
180
+ // (b) the published compiler emits none of it — the blocker is real, not a guess.
181
+ // When this starts failing, upstream task() grew marker support: un-park retro
182
+ // by threading `retroFlow` back into its PORTS entry in ./flows.ts.
183
+ assertEquals(markers(derived), 0, "@nanobpm/workflow can now emit <zeebe:agentDefinition/> — un-park retro");
184
+
185
+ // (c) strip exactly the marker lines from the golden and the port derives the
186
+ // WHOLE model green, through the shared harness's own normalize/equality —
187
+ // so nothing but the marker diverges.
188
+ const unmarked = golden.replace(/^[ \t]*<(?:\w+:)?agentDefinition\b[^>]*\/>[ \t]*\r?\n/gm, "");
189
+ assertEquals(markers(unmarked), 0, "the strip must remove every marker line");
190
+ const expected = normalize(unmarked);
191
+ const actual = normalize(derived);
192
+ assert(
193
+ modelsEqual(expected, actual),
194
+ `retro's port must derive its golden once the agent-task marker is stripped — residual drift:\n${diffModels(expected, actual)}`,
195
+ );
196
+ });
@@ -9,10 +9,9 @@
9
9
  // the structurally-derivable goldens at full whole-model parity, park the rest
10
10
  // pending an upstream construct, and do NOT relax to node-surface parity):
11
11
  //
12
- // • `retro` is a GREEN whole-model parity port (see below). The remaining seven
13
- // goldens are `blockedReason`-parked, in TWO distinct classes, each awaiting
14
- // an upstream `@nanobpm/workflow` (nano-ide) construct + re-release — never a
15
- // golden edit and never relaxed acceptance:
12
+ // • EVERY golden is currently `blockedReason`-parked, in THREE distinct
13
+ // classes, each awaiting an upstream `@nanobpm/workflow` (nano-ide) construct
14
+ // + re-release — never a golden edit and never relaxed acceptance:
16
15
  //
17
16
  // (1) MULTI top-level start/end (spine-demo, readiness-gate, feature,
18
17
  // merge-loop, plan-fanout, delivery-human). `@nanobpm/workflow` derives EXACTLY
@@ -35,6 +34,19 @@
35
34
  // arbitrary-graph / explicit-join (named-target) builder — a SUPERSET of
36
35
  // the class-(1) gap.
37
36
  //
37
+ // (3) ENGINE-NATIVE AGENT-TASK MARKER (retro). `retro` clears BOTH classes
38
+ // above and WAS a green whole-model parity port: its single start/end,
39
+ // linear pipeline, `deviations?` branch, userTask, data envelopes, prompt
40
+ // bindings and general `io` mappings all derive faithfully — the complete
41
+ // port is still authored below, and `derivation-parity.test.ts` proves it
42
+ // differs from its golden by NOTHING but the marker. Issue #745 then added
43
+ // `<zeebe:agentDefinition agentType="external" />` to every prompt-bearing
44
+ // `senior:*` task (a marker `app/agentic/vocab/agent-marker.test.ts`
45
+ // requires, so the golden cannot drop it), and the published compiler
46
+ // cannot emit it: `task()` accepts only `{ jobType, prompt, io }`, and no
47
+ // `agentDefinition`/`agentType` appears anywhere in
48
+ // `@nanobpm/workflow@0.14.0`. Needs an agent-marker option on `task()`.
49
+ //
38
50
  // A resumed run flips any parked model to a real `flow` once the corresponding
39
51
  // upstream construct lands and `@nanobpm/workflow` is bumped to carry it.
40
52
 
@@ -60,7 +72,7 @@ export type PortEntry =
60
72
  readonly blockedReason: string;
61
73
  };
62
74
 
63
- // ── retro (GREENwhole-model parity) ───────────────────────────────────────
75
+ // ── retro (PARKEDclass 3: agent-task marker; the port is otherwise COMPLETE)
64
76
  // retro is a single-start/single-end model: a linear gather → conformance →
65
77
  // record-conformance agent pipeline, a `deviations?` exclusive gateway guarding a
66
78
  // conformance-escalation subgraph (nano-workforce #355/#356), then a shared
@@ -72,6 +84,15 @@ export type PortEntry =
72
84
  // (nano-ide#405) — `w.task`+`io` for `record-conformance-ack`'s general
73
85
  // <zeebe:ioMapping> (inputs `=planKey`→planKey and
74
86
  // `=if (is defined(note)) then note else null`→note).
87
+ //
88
+ // The port below is therefore kept WHOLE and exported, not deleted: it is a
89
+ // faithful whole-model derivation whose ONLY divergence from the golden is the
90
+ // `<zeebe:agentDefinition agentType="external" />` marker issue #745 added to the
91
+ // two `senior:*` tasks (class 3 above). `derivation-parity.test.ts` asserts that
92
+ // divergence is exactly the marker, so the moment upstream `task()` grows an
93
+ // agent-marker option this diagnostic fires and the model is un-parked by
94
+ // threading `retroFlow` back into its PORTS entry — a one-line change, with the
95
+ // port already proven correct rather than re-authored from scratch.
75
96
 
76
97
  /** The typed data envelopes retro's non-agent service tasks lift into the model
77
98
  * (`nano:shape` + `io.nanobpm.dataEnvelope.in`), matching the golden's shapes. */
@@ -96,8 +117,10 @@ const ConformanceRecordIn = envelope("ConformanceRecordIn", {
96
117
  summary: { type: "string", optional: true },
97
118
  });
98
119
 
99
- /** The code-first port of `resources/processes/retro.bpmn`. */
100
- const retroFlow: DeclarativeFlow = defineFlow(
120
+ /** The code-first port of `resources/processes/retro.bpmn` — complete but for the
121
+ * class-3 agent-task marker, so it is exported for the parity diagnostic that
122
+ * pins the divergence to exactly that marker (and parked in PORTS below). */
123
+ export const retroFlow: DeclarativeFlow = defineFlow(
101
124
  "retro",
102
125
  {
103
126
  gather: { in: RetroGatherIn },
@@ -146,9 +169,26 @@ const MULTI_START_END_BLOCK =
146
169
  "cannot reproduce. Awaits an upstream terminal/explicit-end (+ multi-start) " +
147
170
  "construct in @nanobpm/workflow (nano-ide).";
148
171
 
172
+ /** The engine-native AgentTask marker limitation (issue #745), the `blockedReason`
173
+ * for a golden whose ONLY underivable feature is `<zeebe:agentDefinition
174
+ * agentType="external" />` on its agent service tasks. */
175
+ const AGENT_MARKER_BLOCK =
176
+ "blocked (agent-task marker): single top-level start/end and a fully " +
177
+ "structured topology, so this golden derives EXCEPT for the " +
178
+ "<zeebe:agentDefinition agentType=\"external\" /> marker issue #745 added to " +
179
+ "its prompt-bearing senior:* tasks — a marker the published compiler cannot " +
180
+ "emit (task() accepts only { jobType, prompt, io }, and no agentDefinition " +
181
+ "appears anywhere in @nanobpm/workflow@0.14.0). The golden cannot drop the " +
182
+ "marker: app/agentic/vocab/agent-marker.test.ts is a defect-class guard " +
183
+ "requiring it on every deployed prompt-bearing agent task. Proven in the test " +
184
+ "suite — the complete port (retroFlow, still authored here) differs from the " +
185
+ "golden by nothing else. Awaits an agent-marker option on task() upstream in " +
186
+ "@nanobpm/workflow (nano-ide); flip this entry back to `flow: retroFlow` when " +
187
+ "it lands.";
188
+
149
189
  /** All ports, keyed by model, in the epic's stated authoring order. */
150
190
  export const PORTS: readonly PortEntry[] = [
151
- { model: "retro", flow: retroFlow },
191
+ { model: "retro", blockedReason: AGENT_MARKER_BLOCK },
152
192
  {
153
193
  model: "spine-demo",
154
194
  blockedReason: `${MULTI_START_END_BLOCK} (spine-demo: 1 start, 2 ends)`,