@nanobpm/nano-workforce 0.139.4 → 0.140.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,9 @@
1
+ ## [0.140.0](https://github.com/nanobpm/nano-workforce/compare/v0.139.4...v0.140.0) (2026-08-25)
2
+
3
+ ### Features
4
+
5
+ * **agentic:** emit transcriptUrl as a job-output variable (Stage 0, [#543](https://github.com/nanobpm/nano-workforce/issues/543)) ([#547](https://github.com/nanobpm/nano-workforce/issues/547)) ([26b8410](https://github.com/nanobpm/nano-workforce/commit/26b84105405f283acad35ea4026e247fc92d607c)), closes [#544](https://github.com/nanobpm/nano-workforce/issues/544) [#486](https://github.com/nanobpm/nano-workforce/issues/486) [#486](https://github.com/nanobpm/nano-workforce/issues/486)
6
+
1
7
  ## [0.139.4](https://github.com/nanobpm/nano-workforce/compare/v0.139.3...v0.139.4) (2026-08-25)
2
8
 
3
9
  ### Bug Fixes
@@ -292,6 +292,37 @@ test("retention: a disconnected producer auto-completes its ephemeral stream on
292
292
  service.teardown();
293
293
  });
294
294
 
295
+ test("#486 live fallback: an uncompleted stream's ring is served pre-flush, then yields to the durable store", () => {
296
+ const registry = new ConnectionRegistry();
297
+ const hub = capturingHub();
298
+ const service = new RelayTranscriptService({
299
+ hub,
300
+ registry,
301
+ db: memoryDb(),
302
+ log: noopLog(),
303
+ now: () => "2026-03-04T05:06:07.000Z",
304
+ });
305
+ const p = connect("prod", registry);
306
+ for (let i = 0; i < 3; i++) hub.handler?.(produce(jobStream("Lk1"), 1, `r${i}`), p.conn);
307
+
308
+ // The job has completed and emitted its transcriptUrl, but this multiplexing worker is still live so
309
+ // no disconnect/supersede flushed the ring — the durable store still 404s (#486). The live fallback
310
+ // exposes the captured ring + its opened-at instant so the read path can serve it immediately.
311
+ assertEquals(service.transcriptOf(jobStream("Lk1")), undefined, "not yet flushed while the worker is live");
312
+ const live = service.liveFallback(jobStream("Lk1"));
313
+ assert(live !== undefined, "a live, unflushed stream has a serveable ring");
314
+ assertEquals(live.createdAt, "2026-03-04T05:06:07.000Z", "createdAt is the stream's opened-at instant");
315
+ assertEquals(live.ring.since(0).entries.length, 3, "the whole captured window is available");
316
+ assertEquals(live.ring.nextOffset, 3);
317
+
318
+ // Once the stream completes (flushed to durable), the durable store is the source of truth and the
319
+ // live fallback steps aside so a completed transcript is never double-sourced.
320
+ service.completeStream(jobStream("Lk1"));
321
+ assertEquals(service.transcriptOf(jobStream("Lk1"))?.status, "completed");
322
+ assertEquals(service.liveFallback(jobStream("Lk1")), undefined, "a completed stream no longer falls back to the ring");
323
+ service.teardown();
324
+ });
325
+
295
326
  test("H6 correlation write-side: a produce on job:<k> links instance→[k]; stream completion releases it", () => {
296
327
  const registry = new ConnectionRegistry();
297
328
  const correlation = new CorrelationRegistry();
@@ -90,6 +90,14 @@ interface StreamState {
90
90
  * instance is not yet resolvable (a register/produce race), so a later `produce` frame retries.
91
91
  */
92
92
  linked: boolean;
93
+ /**
94
+ * When this stream's in-memory state was first opened, ISO-8601 (stamped from the service clock).
95
+ * The durable transcript row carries its own `created_at` (stamped at flush/`open`), but a
96
+ * still-live ephemeral stream has no durable row yet (#486): its ring holds the captured bytes but
97
+ * the store 404s until a producer disconnect / supersede flushes it. This is the authoritative
98
+ * "when opened" for {@link RelayTranscriptService.liveFallback} to serve the pre-flush ring.
99
+ */
100
+ createdAt: string;
93
101
  /**
94
102
  * The worker instance a `job:<jobKey>` stream was linked under (H6). Recorded so a stream's release
95
103
  * (completion / disconnect) can tidy the {@link RelayTranscriptService.#jobStreamByInstance}
@@ -536,10 +544,30 @@ export class RelayTranscriptService {
536
544
  }
537
545
  }
538
546
 
547
+ /**
548
+ * The still-live relay ring for a stream, for the read path to serve BEFORE a durable flush (#486).
549
+ *
550
+ * A multiplexing worker relays every job over one long-lived connection, one job at a time, so an
551
+ * ephemeral job stream is only flushed to the durable store when the worker disconnects or a NEW
552
+ * job supersedes it — NOT when the job itself completes. In the window between "job completed
553
+ * (`transcriptUrl` emitted)" and that flush, {@link TranscriptStore.get} returns undefined and the
554
+ * transcript endpoint would 404 the freshly-emitted URL. This exposes the live ring (+ its opened-at
555
+ * instant) so {@link readTranscriptFrom} can serve the captured bytes directly, making the URL
556
+ * readable the moment it is emitted. Returns undefined when there is no live ring, or once the
557
+ * stream has been completed (the durable store is then the source of truth).
558
+ */
559
+ liveFallback(stream: string): { ring: TranscriptRing; createdAt: string } | undefined {
560
+ const ring = this.relay.ring(stream);
561
+ if (ring === undefined) return undefined;
562
+ const state = this.#streams.get(stream);
563
+ if (state?.completed) return undefined;
564
+ return { ring, createdAt: state?.createdAt ?? this.#now() };
565
+ }
566
+
539
567
  #stateFor(stream: string): StreamState {
540
568
  let state = this.#streams.get(stream);
541
569
  if (state === undefined) {
542
- state = { lifecycle: "ephemeral", completed: false, linked: false };
570
+ state = { lifecycle: "ephemeral", completed: false, linked: false, createdAt: this.#now() };
543
571
  this.#streams.set(stream, state);
544
572
  }
545
573
  return state;
@@ -6,10 +6,10 @@
6
6
  // interaction with a missing/invalid createdAt — where a hand-built store lets us fix exact timestamps.
7
7
  import { test } from "node:test";
8
8
  import { DatabaseSync } from "node:sqlite";
9
- import type { SqliteDb, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
9
+ import type { SqliteDb, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
10
10
  import { assert, assertEquals } from "#test-assert";
11
11
  import { AgenticCorrelationStore } from "./correlation-store.ts";
12
- import { correlationFieldsFor, listTranscripts } from "./transcript-read.ts";
12
+ import { correlationFieldsFor, listTranscripts, readTranscriptFrom } from "./transcript-read.ts";
13
13
 
14
14
  /** A read-only TranscriptStore double: list() returns the seeded metas; read() has no retained chunks. */
15
15
  function fakeStore(metas: TranscriptStream[]): TranscriptStore {
@@ -122,3 +122,96 @@ test("listTranscripts: the instance filter returns only sessions the durable sto
122
122
  );
123
123
  assert(out.every((t) => t.instance === "worker-A"), "each row is attributed to worker-A");
124
124
  });
125
+
126
+ // --- #486: the still-live-ring read fallback that makes a freshly-emitted transcriptUrl readable ---
127
+ //
128
+ // A multiplexing worker relays every job over one long-lived connection and only flushes a job's ring
129
+ // to the durable store when it disconnects or a NEW job supersedes it — NOT when the job completes. In
130
+ // the window between "job completed (transcriptUrl emitted)" and that flush, the durable store has no
131
+ // row, so the transcript endpoint must serve the live ring or it would 404 the URL it just emitted.
132
+
133
+ /** A minimal live ring double satisfying {@link TranscriptRing}: the whole retained window from `from`. */
134
+ function fakeRing(entries: { offset: number; chunk: string }[]): TranscriptRing {
135
+ const nextOffset = entries.length === 0 ? 0 : entries[entries.length - 1].offset + 1;
136
+ return {
137
+ since: (from: number) => ({ entries: entries.filter((e) => e.offset >= from) }),
138
+ nextOffset,
139
+ };
140
+ }
141
+
142
+ /** A store double whose `get` returns a seeded row (or undefined), with the matching `since` window. */
143
+ function getStore(row: TranscriptStream | undefined, entries: { offset: number; chunk: string }[] = []): TranscriptStore {
144
+ return {
145
+ get: (_stream: string) => row,
146
+ since: (_stream: string, from: number) => ({
147
+ entries: entries.filter((e) => e.offset >= from),
148
+ gap: false,
149
+ nextOffset: row?.nextOffset ?? 0,
150
+ }),
151
+ } as unknown as TranscriptStore;
152
+ }
153
+
154
+ test("readTranscriptFrom: falls back to the live ring when the durable store has no row (#486)", () => {
155
+ const ring = fakeRing([
156
+ { offset: 0, chunk: "hello " },
157
+ { offset: 1, chunk: "world" },
158
+ ]);
159
+ const out = readTranscriptFrom("job:live1", 0, getStore(undefined), undefined, undefined, {
160
+ ring,
161
+ createdAt: mid,
162
+ });
163
+ assert(out !== undefined, "a live-but-unflushed stream is readable, not a 404");
164
+ assertEquals(out.status, "open", "an unflushed live stream reads as open");
165
+ assertEquals(out.lifecycle, "ephemeral");
166
+ assertEquals(out.createdAt, mid);
167
+ assertEquals(out.nextOffset, 2);
168
+ assertEquals(out.chunkCount, 2);
169
+ assertEquals(
170
+ out.entries.map((e) => e.chunk).join(""),
171
+ "hello world",
172
+ "the captured bytes are served straight from the ring",
173
+ );
174
+ assertEquals(out.jobKey, "live1", "the jobKey is still decoded from the stream id");
175
+ });
176
+
177
+ test("readTranscriptFrom: the live-ring fallback honours the resume-from offset", () => {
178
+ const ring = fakeRing([
179
+ { offset: 0, chunk: "a" },
180
+ { offset: 1, chunk: "b" },
181
+ { offset: 2, chunk: "c" },
182
+ ]);
183
+ const out = readTranscriptFrom("job:live2", 2, getStore(undefined), undefined, undefined, { ring, createdAt: mid });
184
+ assert(out !== undefined);
185
+ assertEquals(out.from, 2);
186
+ assertEquals(
187
+ out.entries.map((e) => e.chunk).join(""),
188
+ "c",
189
+ "only chunks at/after the requested offset are replayed",
190
+ );
191
+ });
192
+
193
+ test("readTranscriptFrom: prefers the durable store once the ring has been flushed", () => {
194
+ const row: TranscriptStream = {
195
+ stream: "job:flushed",
196
+ lifecycle: "ephemeral",
197
+ status: "completed",
198
+ createdAt: early,
199
+ completedAt: late,
200
+ nextOffset: 1,
201
+ };
202
+ const store = getStore(row, [{ offset: 0, chunk: "durable" }]);
203
+ // A live ring is ALSO provided, but the flushed durable row wins (it is the source of truth once flushed).
204
+ const out = readTranscriptFrom("job:flushed", 0, store, undefined, undefined, {
205
+ ring: fakeRing([{ offset: 0, chunk: "stale-ring" }]),
206
+ createdAt: mid,
207
+ });
208
+ assert(out !== undefined);
209
+ assertEquals(out.status, "completed", "the flushed durable row is served, not the live ring");
210
+ assertEquals(out.completedAt, late);
211
+ assertEquals(out.entries.map((e) => e.chunk).join(""), "durable");
212
+ });
213
+
214
+ test("readTranscriptFrom: returns undefined when neither the store nor a live ring has the stream", () => {
215
+ const out = readTranscriptFrom("job:gone", 0, getStore(undefined), undefined, undefined, undefined);
216
+ assertEquals(out, undefined);
217
+ });
@@ -14,7 +14,7 @@
14
14
  // Pure and side-effect-free apart from reading the store: no I/O beyond the injected store, so it is
15
15
  // unit-testable on the injected env (Node, no browser), and never touches the engine or a BPMN flow.
16
16
 
17
- import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
17
+ import type { TranscriptChunk, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
18
18
  import type { AgenticTranscript, AgenticTranscriptData } 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";
@@ -163,33 +163,64 @@ export function listTranscripts(
163
163
  /**
164
164
  * Fetch a stored transcript's bytes from offset `from` (inclusive), projected onto the range/offset
165
165
  * wire shape — the SAME resume-from-offset contract the live terminal renders, so the cockpit replays
166
- * a closed stream through its existing renderer. Returns undefined when the stream has no transcript.
166
+ * a closed stream through its existing renderer.
167
+ *
168
+ * Reads the durable {@link TranscriptStore} first; when the store has no row for the stream (it was
169
+ * never persisted, or — the #486 caveat — the job completed on a still-live multiplexing worker whose
170
+ * ring has not been flushed yet) it falls back to the injected live ring so a freshly-emitted
171
+ * `transcriptUrl` is readable the moment it is emitted. Returns undefined only when neither the store
172
+ * nor a live ring has the stream.
167
173
  */
168
174
  export function readTranscriptFrom(
169
175
  stream: string,
170
176
  from: number,
171
- store: TranscriptStore,
177
+ store: TranscriptStore | undefined,
172
178
  correlation: CorrelationRegistry | undefined,
173
179
  durable?: AgenticCorrelationStore | undefined,
180
+ live?: { ring: TranscriptRing; createdAt: string } | undefined,
174
181
  ): AgenticTranscriptData | undefined {
175
- const meta = store.get(stream);
176
- if (meta === undefined) return undefined;
177
- const slice = store.since(stream, from);
178
- const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
179
- const out: AgenticTranscriptData = {
180
- stream: meta.stream,
181
- lifecycle: meta.lifecycle,
182
- status: meta.status,
183
- createdAt: meta.createdAt,
184
- nextOffset: slice.nextOffset,
185
- byteLength: byteLengthOf(slice.entries),
186
- chunkCount: entries.length,
187
- from,
188
- gap: slice.gap,
189
- entries,
190
- };
191
- if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
192
- const fields = correlationFieldsFor(meta.stream, correlation, durable);
182
+ const meta = store?.get(stream);
183
+ let out: AgenticTranscriptData;
184
+ if (store !== undefined && meta !== undefined) {
185
+ const slice = store.since(stream, from);
186
+ const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
187
+ out = {
188
+ stream: meta.stream,
189
+ lifecycle: meta.lifecycle,
190
+ status: meta.status,
191
+ createdAt: meta.createdAt,
192
+ nextOffset: slice.nextOffset,
193
+ byteLength: byteLengthOf(slice.entries),
194
+ chunkCount: entries.length,
195
+ from,
196
+ gap: slice.gap,
197
+ entries,
198
+ };
199
+ if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
200
+ } else if (live !== undefined) {
201
+ // #486: no durable row yet — the job completed on a still-live multiplexing worker whose ring has
202
+ // not been flushed. Serve the captured bytes straight from the live ring so a freshly-emitted
203
+ // `transcriptUrl` is readable immediately (an open ephemeral stream), rather than 404-ing until the
204
+ // worker disconnects. `gap` is derived structurally: the first returned entry sitting past `from`
205
+ // means retention already evicted the requested prefix.
206
+ const slice = live.ring.since(from);
207
+ const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
208
+ out = {
209
+ stream,
210
+ lifecycle: "ephemeral",
211
+ status: "open",
212
+ createdAt: live.createdAt,
213
+ nextOffset: live.ring.nextOffset,
214
+ byteLength: byteLengthOf(slice.entries),
215
+ chunkCount: entries.length,
216
+ from,
217
+ gap: entries.length > 0 && entries[0].offset > from,
218
+ entries,
219
+ };
220
+ } else {
221
+ return undefined;
222
+ }
223
+ const fields = correlationFieldsFor(out.stream, correlation, durable);
193
224
  if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
194
225
  if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
195
226
  if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
@@ -0,0 +1,35 @@
1
+ // Unit coverage for the transcript-URL SSOT (#543): the base a worker prepends and the full per-job
2
+ // URL it emits must derive from ONE path string and the ONE jobStream() encoder, so the seed the
3
+ // dispatcher hands each agent job, the endpoint route, and the tests can never drift apart.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import { jobStream } from "./correlation.ts";
7
+ import {
8
+ TRANSCRIPT_URL_BASE_VAR,
9
+ TRANSCRIPT_URL_VAR,
10
+ transcriptUrlBaseFor,
11
+ transcriptUrlForJob,
12
+ } from "./transcript-url.ts";
13
+
14
+ const BASE = "https://nano.example.com";
15
+
16
+ test("the variable names are the stable wire contract Explorer and the worker agree on", () => {
17
+ assertEquals(TRANSCRIPT_URL_VAR, "transcriptUrl");
18
+ assertEquals(TRANSCRIPT_URL_BASE_VAR, "transcriptUrlBase");
19
+ });
20
+
21
+ test("transcriptUrlBaseFor: the seeded base is the app mount's transcript endpoint, trailing-slashed", () => {
22
+ assertEquals(transcriptUrlBaseFor(BASE), `${BASE}/app/api/agentic/transcripts/`);
23
+ });
24
+
25
+ test("transcriptUrlForJob: the full URL is the base + the jobKey-scoped stream id", () => {
26
+ const jobKey = "2251799813685249";
27
+ assertEquals(transcriptUrlForJob(jobKey, BASE), `${BASE}/app/api/agentic/transcripts/${jobStream(jobKey)}`);
28
+ });
29
+
30
+ test("derivation: transcriptUrlForJob is exactly transcriptUrlBaseFor + jobStream — no second path source", () => {
31
+ // This is the anti-drift invariant: the value a worker emits and the base the dispatcher seeds share
32
+ // a single origin, so a route change in one place can never leave the other pointing at a 404.
33
+ const jobKey = "job-abc";
34
+ assertEquals(transcriptUrlForJob(jobKey, BASE), `${transcriptUrlBaseFor(BASE)}${jobStream(jobKey)}`);
35
+ });
@@ -0,0 +1,54 @@
1
+ // nano-workforce — the agent-job transcript URL contract (ADR 0006 §4b, Stage 0 / #543).
2
+ //
3
+ // The zero-infra correlation slice: on agentic job completion the completing worker emits a
4
+ // `transcriptUrl` output variable pointing at the durable HTTP transcript endpoint for its own
5
+ // `jobKey`, so Nano Explorer's variables panel links a process run to its agent transcript with no
6
+ // Explorer change and no new correlation infra (that sharper element-instance keying is Stage 1,
7
+ // #544). This module is the SINGLE SOURCE OF TRUTH for that URL's shape: the seed the dispatcher hands
8
+ // each agent job, the value the worker emits, and the tests all derive from it, so they cannot drift
9
+ // from one another. It is authored to match the endpoint route the `getAgenticTranscript` operation
10
+ // serves (`GET /app/api/agentic/transcripts/{stream}` in `openapi.yaml`) — see {@link TRANSCRIPT_STREAM_PATH}.
11
+ //
12
+ // The value is worker-supplied because only the completing worker knows its own `jobKey` (the engine
13
+ // exposes no job/element/process key to an output-mapping FEEL context — it is job metadata, not a
14
+ // variable). So the app SEEDS the base URL onto the agent job (`transcriptUrlBase`) and the worker
15
+ // appends its jobKey-scoped stream id, keeping the fleet worker app-agnostic (a bare concatenation)
16
+ // while this module owns every path segment.
17
+
18
+ import { publicBaseUrl } from "../blackboard.ts";
19
+ import { jobStream } from "./correlation.ts";
20
+
21
+ /** The job-output variable a completed agent job carries its transcript URL on (rendered by Explorer). */
22
+ export const TRANSCRIPT_URL_VAR = "transcriptUrl";
23
+
24
+ /** The input variable the dispatcher seeds onto an agent job so the worker can build {@link TRANSCRIPT_URL_VAR}
25
+ * by appending its own jobKey-scoped stream — the worker never needs to know the app's mount path. */
26
+ export const TRANSCRIPT_URL_BASE_VAR = "transcriptUrlBase";
27
+
28
+ /** The control-API path (under the app mount) the transcript stream endpoint is served at, ending in a
29
+ * trailing slash so a `{stream}` id appends cleanly. This is the app-tier authoring used to BUILD the
30
+ * transcript URL (the seed the dispatcher hands each agent job, the value the worker emits, and the
31
+ * tests) — the single origin those derive from, so they cannot drift from one another. It is NOT the
32
+ * sole authoring of the route string itself: the endpoint route is declared by the `getAgenticTranscript`
33
+ * operation (`GET /app/api/agentic/transcripts/{stream}` in `openapi.yaml`), and the cockpit embed
34
+ * carries its own module-anchored default (`pages/cockpit/mount.js`). Keep this value in step with that
35
+ * route. */
36
+ const TRANSCRIPT_STREAM_PATH = "/app/api/agentic/transcripts/";
37
+
38
+ /**
39
+ * The externally-reachable base a worker prepends to its jobKey-scoped stream id to form the
40
+ * transcript URL: `<publicBaseUrl>/app/api/agentic/transcripts/`. The worker appends `job:<jobKey>`
41
+ * ({@link jobStream}). Trailing slash included so the concatenation is a bare append.
42
+ */
43
+ export function transcriptUrlBaseFor(base: string = publicBaseUrl()): string {
44
+ return `${base}${TRANSCRIPT_STREAM_PATH}`;
45
+ }
46
+
47
+ /**
48
+ * The full durable transcript URL for a completed job's `jobKey` — the value a worker emits on
49
+ * {@link TRANSCRIPT_URL_VAR}. Derived from {@link transcriptUrlBaseFor} + {@link jobStream} so it can
50
+ * never disagree with the base the dispatcher seeds or the endpoint route it resolves to.
51
+ */
52
+ export function transcriptUrlForJob(jobKey: string, base: string = publicBaseUrl()): string {
53
+ return `${transcriptUrlBaseFor(base)}${jobStream(jobKey)}`;
54
+ }
@@ -81,6 +81,33 @@ test("happy path: a well-formed graph compiles to a full preview with no side ef
81
81
  assertEquals(r.sideEffects.length, 2);
82
82
  });
83
83
 
84
+ test("#543 transcript correlation: only an agent node seeds transcriptUrlBase and emits transcriptUrl", async () => {
85
+ const r = await compileOk(RELEASE_RUNBOOK);
86
+ // The agent node's subProcess ioMapping threads the seeded base IN (so the completing worker can
87
+ // build its own jobKey-scoped URL) and propagates the worker-set transcriptUrl OUT to the instance.
88
+ assert(
89
+ /<zeebe:input source="=if \(is defined\(transcriptUrlBase\)\) then transcriptUrlBase else null" target="transcriptUrlBase"/.test(
90
+ r.bpmn,
91
+ ),
92
+ "an agent node seeds transcriptUrlBase",
93
+ );
94
+ assert(
95
+ /<zeebe:output source="=if \(is defined\(transcriptUrl\)\) then transcriptUrl else null" target="transcriptUrl"/.test(r.bpmn),
96
+ "an agent node propagates the worker-emitted transcriptUrl up to the instance scope",
97
+ );
98
+ // RELEASE_RUNBOOK has exactly ONE agent node — wait/human/connector must NOT carry the mapping.
99
+ assertEquals(
100
+ (r.bpmn.match(/target="transcriptUrl"/g) ?? []).length,
101
+ 1,
102
+ "only the agent node emits transcriptUrl (non-agent kinds do not)",
103
+ );
104
+ assertEquals(
105
+ (r.bpmn.match(/target="transcriptUrlBase"/g) ?? []).length,
106
+ 1,
107
+ "only the agent node seeds transcriptUrlBase",
108
+ );
109
+ });
110
+
84
111
  test("determinism: the same JSON always yields byte-identical bpmn/diagram/resolved", async () => {
85
112
  const a = await compileOk(RELEASE_RUNBOOK);
86
113
  const b = await compileOk(RELEASE_RUNBOOK);
@@ -35,6 +35,7 @@ import type {
35
35
  ResolvedDeliveryEdge,
36
36
  ResolvedDeliveryNode,
37
37
  } from "../nano-generated/api-io.d.ts";
38
+ import { TRANSCRIPT_URL_BASE_VAR, TRANSCRIPT_URL_VAR } from "./agentic/transcript-url.ts";
38
39
  import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
39
40
  import {
40
41
  analyzeExclusiveTopology,
@@ -815,6 +816,11 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
815
816
  inputs.push({ source: cfg("jobType"), target: "jobType" });
816
817
  inputs.push({ source: cfg("appendPrompt"), target: "appendPrompt" });
817
818
  inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
819
+ // Stage 0 transcript correlation (#543): seed the transcript URL base so the completing fleet
820
+ // worker can append its own jobKey-scoped stream and emit `transcriptUrl` (below). `transcriptUrlBase`
821
+ // is a top-level launch variable (deliveryRunner) — guarded so a hand-seeded instance without it
822
+ // threads null rather than raising a FEEL error.
823
+ inputs.push({ source: guarded(TRANSCRIPT_URL_BASE_VAR), target: TRANSCRIPT_URL_BASE_VAR });
818
824
  break;
819
825
  case "wait":
820
826
  inputs.push({ source: cfg("gateKey"), target: "gateKey" });
@@ -862,6 +868,14 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
862
868
  outputs.push({ source: guarded(factSourceVar(node.kind, fact)), target: `${el}_${fact.name}` });
863
869
  }
864
870
 
871
+ // Stage 0 transcript correlation (#543): propagate the completing worker's `transcriptUrl` (built
872
+ // from the seeded base + its jobKey) up to the process-instance scope, where Nano Explorer's
873
+ // variables panel renders it as the link from this run to the agent's transcript. Guarded so a job
874
+ // completed without it (an older fleet worker) threads null instead of raising a FEEL error.
875
+ if (node.kind === "agent") {
876
+ outputs.push({ source: guarded(TRANSCRIPT_URL_VAR), target: TRANSCRIPT_URL_VAR });
877
+ }
878
+
865
879
  const lines: string[] = [" <zeebe:ioMapping>"];
866
880
  for (const i of inputs) lines.push(` <zeebe:input ${attr("source", i.source)} target="${i.target}" />`);
867
881
  for (const o of outputs) lines.push(` <zeebe:output ${attr("source", o.source)} target="${o.target}" />`);
@@ -22,6 +22,13 @@ import { assert, assertEquals } from "#test-assert";
22
22
  import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
23
23
  import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
24
24
  import { runDeliveryGraph } from "./deliveryRunner.ts";
25
+ import { jobStream } from "./agentic/correlation.ts";
26
+ import {
27
+ TRANSCRIPT_URL_BASE_VAR,
28
+ TRANSCRIPT_URL_VAR,
29
+ transcriptUrlBaseFor,
30
+ transcriptUrlForJob,
31
+ } from "./agentic/transcript-url.ts";
25
32
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
26
33
 
27
34
  /** A graph exercising the full node-kind matrix: `agent` (a named `senior:*` job), `wait` (the
@@ -208,6 +215,78 @@ function escapeRe(s: string): string {
208
215
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
209
216
  }
210
217
 
218
+ /** Read a running process instance's scope variables out of the wasm engine's raw snapshot, by key. */
219
+ function instanceVariables(engine: { snapshot(): Record<string, unknown> }, key: string): Record<string, unknown> {
220
+ const snap = engine.snapshot();
221
+ const instances = snap.instances;
222
+ assert(Array.isArray(instances), "snapshot.instances is an array of instance rows");
223
+ const row = instances.find((i): i is { key: string; variables: Record<string, unknown> } => {
224
+ return typeof i === "object" && i !== null && (i as { key?: unknown }).key === key;
225
+ });
226
+ assert(row !== undefined, `no snapshot instance row for ${key}`);
227
+ return row.variables ?? {};
228
+ }
229
+
230
+ test("#543 transcript correlation: a completed agent job exposes a resolvable instance-scope transcriptUrl", async () => {
231
+ const engine = await createWasmEngineClient();
232
+ try {
233
+ // A minimal agent→human graph: the agent job completes (emitting its transcript URL the way the
234
+ // real fleet worker does — the seeded base + its own jobKey-scoped stream), then the instance parks
235
+ // on the human node so its scope variables are still inspectable (a bare agent graph would COMPLETE
236
+ // and drop them). The worker captures its jobKey so the test can assert the exact URL the SSOT
237
+ // builder yields for it.
238
+ let workerJobKey = "";
239
+ let seededBase: unknown;
240
+ await engine.registerWorker(
241
+ "senior:feature",
242
+ async (job) => {
243
+ workerJobKey = String(job.jobKey);
244
+ seededBase = job.variables?.transcriptUrlBase;
245
+ // Mirror the harness: append the jobKey-scoped stream id to the app-seeded base (#486/#543).
246
+ return { transcriptUrl: `${String(job.variables?.transcriptUrlBase)}${jobStream(workerJobKey)}` };
247
+ },
248
+ { fetchVariables: [TRANSCRIPT_URL_BASE_VAR] },
249
+ );
250
+
251
+ const graph: DeliveryGraph = {
252
+ name: "transcript correlation",
253
+ nodes: [
254
+ { id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "ship it" } },
255
+ { id: "review", kind: "human", human: { prompt: "review the run" } },
256
+ ],
257
+ edges: [{ from: "impl", to: "review" }],
258
+ };
259
+ const run = await runDeliveryGraph(engine, graph);
260
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
261
+ const key = run.handle.processInstanceKey;
262
+
263
+ // Drive until the agent node has completed and the instance parks on the human user task.
264
+ let parked = false;
265
+ for (let round = 0; round < MAX_ROUNDS; round++) {
266
+ await engine.drain();
267
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
268
+ if (open.length > 0) {
269
+ parked = true;
270
+ break;
271
+ }
272
+ }
273
+ assert(parked, "the agent node must complete and the instance park on the human node");
274
+
275
+ // The app seeded the transcript endpoint base onto the agent job (input mapping)...
276
+ assertEquals(seededBase, transcriptUrlBaseFor(), "the agent job receives the seeded transcriptUrlBase");
277
+ // ...and the worker-emitted transcriptUrl propagated up to the process-instance scope (output
278
+ // mapping), resolving to EXACTLY the SSOT URL for that jobKey — the link Nano Explorer renders.
279
+ const vars = instanceVariables(engine, key);
280
+ assertEquals(
281
+ vars[TRANSCRIPT_URL_VAR],
282
+ transcriptUrlForJob(workerJobKey),
283
+ "the completed agent job exposes a resolvable, correct transcriptUrl on the instance",
284
+ );
285
+ } finally {
286
+ await engine.close();
287
+ }
288
+ });
289
+
211
290
  // ── S7: guarded (conditional) routing DEPLOYS and ROUTES on the real engine (ADR 0005 S7) ──────────
212
291
  // The compiler tests prove a guarded split emits an exclusiveGateway with FEEL conditions; only a live
213
292
  // deploy proves the engine EVALUATES those conditions and takes exactly ONE branch. Here the `bump`
@@ -16,6 +16,7 @@
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import type { EngineClient } from "@nanobpm/urban";
18
18
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
+ import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
19
20
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
21
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
21
22
  import { isoDuration } from "./reviewWait.ts";
@@ -147,7 +148,14 @@ export async function runDeliveryGraph(
147
148
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
148
149
  const { processInstanceKey } = await engine.createInstance({
149
150
  processDefinitionId,
150
- variables: { nodeInputs },
151
+ variables: {
152
+ nodeInputs,
153
+ // Stage 0 transcript correlation (#543): the transcript-endpoint base every agent node's
154
+ // completing worker appends its jobKey-scoped stream to, to emit `transcriptUrl` (see the agent
155
+ // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
156
+ // every node — and read down into each agent job via `=transcriptUrlBase`.
157
+ [TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
158
+ },
151
159
  });
152
160
  // The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
153
161
  // downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
package/app/feature.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  // Data access goes through the record gateway (`data.table`), never hand-written
16
16
  // SQL — matching app/plan.ts and app/service.ts.
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
+ import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
18
19
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
20
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
21
  import type { ReadinessProbe } from "./readiness.ts";
@@ -400,6 +401,11 @@ export async function startFeature(
400
401
  probePollEvery: readinessProbes ? (readiness.probePollEvery ?? null) : null,
401
402
  gateKey: readinessProbes ? `feature-readiness:${parsed.planKey}` : null,
402
403
  resolvedArtifacts: null,
404
+ // Stage 0 transcript correlation (#543): the transcript-endpoint base the `implement-task` agent
405
+ // worker appends its jobKey-scoped stream to, to emit a `transcriptUrl` output variable that
406
+ // links this feature run to its agent transcript in Nano Explorer (feature.bpmn `implement-task`
407
+ // ioMapping). Read down into the job via `=transcriptUrlBase`.
408
+ [TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
403
409
  },
404
410
  });
405
411
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -29,13 +29,21 @@ export default defineOperation("getAgenticTranscript", async ({ params, query, r
29
29
  }
30
30
 
31
31
  const service = currentRelayTranscriptService();
32
- const store = service?.store;
33
- if (!store) {
34
- // No transcript store mounted (relay unmounted or unpersisted) - nothing to replay.
32
+ if (!service) {
33
+ // No relay/transcript service mounted at all - nothing to replay.
35
34
  return { status: 404, body: { error: "no transcript for stream" } };
36
35
  }
37
36
 
38
- const data = readTranscriptFrom(params.stream, from, store, currentCorrelation(), service?.correlationStore);
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
+ );
39
47
  if (data === undefined) {
40
48
  return { status: 404, body: { error: "no transcript for stream" } };
41
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.139.4",
3
+ "version": "0.140.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",
@@ -177,8 +177,10 @@
177
177
  </zeebe:linkedResources>
178
178
  <zeebe:ioMapping>
179
179
  <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (baseBranchBrief = null) then &#34;&#34; else baseBranchBrief) + (if (resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then &#34;&#34; else (&#34;&#10;&#10;---&#10;&#10;**Bound upstream readiness (intake gate).** This feature was scheduled to wait until an upstream landed and published. Build/install/clone against EXACTLY these resolved published versions (the ones first carrying the awaited capability), and bump the consumer dependency to them — never merely the newest:&#10;&#10;&#34; + string join(resolvedArtifacts[item != null], &#34;&#10;&#34;))) + (if (customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + customInstructions)" target="appendPrompt" />
180
+ <zeebe:input source="=if (is defined(transcriptUrlBase)) then transcriptUrlBase else null" target="transcriptUrlBase" />
180
181
  <zeebe:output source="=status" target="status" />
181
182
  <zeebe:output source="=question" target="question" />
183
+ <zeebe:output source="=if (is defined(transcriptUrl)) then transcriptUrl else null" target="transcriptUrl" />
182
184
  </zeebe:ioMapping>
183
185
  </bpmn:extensionElements>
184
186
  <bpmn:incoming>f_toImplement</bpmn:incoming>