@nanobpm/nano-workforce 0.139.4 → 0.141.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.
@@ -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";
@@ -33,6 +33,8 @@ interface CorrelationFields {
33
33
  processInstanceKey?: string;
34
34
  bpmnProcessId?: string;
35
35
  elementId?: string;
36
+ /** The engine element-instance key the job's token occupied (#544) — per-occupancy, unlike elementId. */
37
+ elementInstanceKey?: string;
36
38
  planKey?: string;
37
39
  /** The worker instance that ran the job (durable — survives release / restart). */
38
40
  instance?: string;
@@ -63,6 +65,7 @@ export function correlationFieldsFor(
63
65
  if (context.processInstanceKey !== undefined) fields.processInstanceKey = context.processInstanceKey;
64
66
  if (context.bpmnProcessId !== undefined) fields.bpmnProcessId = context.bpmnProcessId;
65
67
  if (context.elementId !== undefined) fields.elementId = context.elementId;
68
+ if (context.elementInstanceKey !== undefined) fields.elementInstanceKey = context.elementInstanceKey;
66
69
  if (context.planKey !== undefined) fields.planKey = context.planKey;
67
70
  }
68
71
  // Durable fallback: fill any field the live registry did not supply (a released past session, or a
@@ -74,6 +77,9 @@ export function correlationFieldsFor(
74
77
  }
75
78
  if (fields.bpmnProcessId === undefined && row.bpmnProcessId !== undefined) fields.bpmnProcessId = row.bpmnProcessId;
76
79
  if (fields.elementId === undefined && row.elementId !== undefined) fields.elementId = row.elementId;
80
+ if (fields.elementInstanceKey === undefined && row.elementInstanceKey !== undefined) {
81
+ fields.elementInstanceKey = row.elementInstanceKey;
82
+ }
77
83
  if (fields.planKey === undefined && row.planKey !== undefined) fields.planKey = row.planKey;
78
84
  if (row.instance !== undefined) fields.instance = row.instance;
79
85
  if (row.identity !== undefined) fields.identity = row.identity;
@@ -106,6 +112,7 @@ export function toTranscript(
106
112
  if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
107
113
  if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
108
114
  if (fields.elementId !== undefined) out.elementId = fields.elementId;
115
+ if (fields.elementInstanceKey !== undefined) out.elementInstanceKey = fields.elementInstanceKey;
109
116
  if (fields.planKey !== undefined) out.planKey = fields.planKey;
110
117
  if (fields.instance !== undefined) out.instance = fields.instance;
111
118
  if (fields.identity !== undefined) out.identity = fields.identity;
@@ -117,6 +124,8 @@ export function toTranscript(
117
124
  export interface TranscriptFilter {
118
125
  readonly jobKey?: string;
119
126
  readonly processInstanceKey?: string;
127
+ /** The engine element-instance key (#544) — resolves a session to one occupancy of a looping activity. */
128
+ readonly elementInstanceKey?: string;
120
129
  readonly planKey?: string;
121
130
  /** The worker instance that ran the session (durable attribution) — powers the worker-history view. */
122
131
  readonly instance?: string;
@@ -145,6 +154,7 @@ export function listTranscripts(
145
154
  .filter((t) => {
146
155
  if (filter.jobKey !== undefined && t.jobKey !== filter.jobKey) return false;
147
156
  if (filter.processInstanceKey !== undefined && t.processInstanceKey !== filter.processInstanceKey) return false;
157
+ if (filter.elementInstanceKey !== undefined && t.elementInstanceKey !== filter.elementInstanceKey) return false;
148
158
  if (filter.planKey !== undefined && t.planKey !== filter.planKey) return false;
149
159
  if (filter.instance !== undefined && t.instance !== filter.instance) return false;
150
160
  const createdMs = Date.parse(t.createdAt);
@@ -163,37 +173,69 @@ export function listTranscripts(
163
173
  /**
164
174
  * Fetch a stored transcript's bytes from offset `from` (inclusive), projected onto the range/offset
165
175
  * 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.
176
+ * a closed stream through its existing renderer.
177
+ *
178
+ * Reads the durable {@link TranscriptStore} first; when the store has no row for the stream (it was
179
+ * never persisted, or — the #486 caveat — the job completed on a still-live multiplexing worker whose
180
+ * ring has not been flushed yet) it falls back to the injected live ring so a freshly-emitted
181
+ * `transcriptUrl` is readable the moment it is emitted. Returns undefined only when neither the store
182
+ * nor a live ring has the stream.
167
183
  */
168
184
  export function readTranscriptFrom(
169
185
  stream: string,
170
186
  from: number,
171
- store: TranscriptStore,
187
+ store: TranscriptStore | undefined,
172
188
  correlation: CorrelationRegistry | undefined,
173
189
  durable?: AgenticCorrelationStore | undefined,
190
+ live?: { ring: TranscriptRing; createdAt: string } | undefined,
174
191
  ): 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);
192
+ const meta = store?.get(stream);
193
+ let out: AgenticTranscriptData;
194
+ if (store !== undefined && meta !== undefined) {
195
+ const slice = store.since(stream, from);
196
+ const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
197
+ out = {
198
+ stream: meta.stream,
199
+ lifecycle: meta.lifecycle,
200
+ status: meta.status,
201
+ createdAt: meta.createdAt,
202
+ nextOffset: slice.nextOffset,
203
+ byteLength: byteLengthOf(slice.entries),
204
+ chunkCount: entries.length,
205
+ from,
206
+ gap: slice.gap,
207
+ entries,
208
+ };
209
+ if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
210
+ } else if (live !== undefined) {
211
+ // #486: no durable row yet — the job completed on a still-live multiplexing worker whose ring has
212
+ // not been flushed. Serve the captured bytes straight from the live ring so a freshly-emitted
213
+ // `transcriptUrl` is readable immediately (an open ephemeral stream), rather than 404-ing until the
214
+ // worker disconnects. `gap` is derived structurally: the first returned entry sitting past `from`
215
+ // means retention already evicted the requested prefix.
216
+ const slice = live.ring.since(from);
217
+ const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
218
+ out = {
219
+ stream,
220
+ lifecycle: "ephemeral",
221
+ status: "open",
222
+ createdAt: live.createdAt,
223
+ nextOffset: live.ring.nextOffset,
224
+ byteLength: byteLengthOf(slice.entries),
225
+ chunkCount: entries.length,
226
+ from,
227
+ gap: entries.length > 0 && entries[0].offset > from,
228
+ entries,
229
+ };
230
+ } else {
231
+ return undefined;
232
+ }
233
+ const fields = correlationFieldsFor(out.stream, correlation, durable);
193
234
  if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
194
235
  if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
195
236
  if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
196
237
  if (fields.elementId !== undefined) out.elementId = fields.elementId;
238
+ if (fields.elementInstanceKey !== undefined) out.elementInstanceKey = fields.elementInstanceKey;
197
239
  if (fields.planKey !== undefined) out.planKey = fields.planKey;
198
240
  if (fields.instance !== undefined) out.instance = fields.instance;
199
241
  if (fields.identity !== undefined) out.identity = fields.identity;
@@ -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);
@@ -0,0 +1,21 @@
1
+ -- Key the durable agent correlation on the ELEMENT INSTANCE, not just the static BPMN element id
2
+ -- (#544, Stage 1 of transcript↔process-run correlation; ADR 0006 §4b intersection, #464).
3
+ --
4
+ -- `078_agentic_correlation.sql` records `element_id` — the STATIC BPMN id — which is ambiguous across
5
+ -- a looping / retried job: the same activity id occupies many distinct element instances over a
6
+ -- process instance's life, so a transcript keyed only by `element_id` cannot say WHICH occupancy a
7
+ -- token was in. `element_instance_key` is the engine's per-occupancy handle (the same one Nano
8
+ -- Explorer addresses runtime position by, and the one Camunda keys its agent model on), resolved from
9
+ -- the agent job's `jobKey` via the engine element-instance wait-state read (nano-ide#473's binding).
10
+ --
11
+ -- Expand-and-contract: this is the EXPAND step — a nullable, additive column alongside the retained
12
+ -- `element_id` (kept during the transition, never dropped here). NULL for pre-#544 rows and whenever
13
+ -- the (advisory, best-effort) resolution did not land, so it never gates a BPMN sequence flow.
14
+ --
15
+ -- Single source of truth: the durable table's canonical DDL is `AGENTIC_CORRELATION_SCHEMA_SQL` in
16
+ -- `app/agentic/correlation-store.ts` (applied idempotently at store construction). This migration
17
+ -- brings an already-078-migrated DB up to that same effective shape; a drift-guard test
18
+ -- (`correlation-store.test.ts`) pins the migrated schema (078 + 086) to the canonical DDL so the two
19
+ -- can never diverge.
20
+ ALTER TABLE agentic_correlation ADD COLUMN element_instance_key TEXT;
21
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_element_instance ON agentic_correlation (element_instance_key);
package/main.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  import { Server } from "node:http";
21
21
  import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urban";
22
22
  import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
23
+ import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
23
24
  import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
24
25
  import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
25
26
  import { envVar } from "./app/version.ts";
@@ -79,6 +80,11 @@ if (httpServer instanceof Server) {
79
80
  secret: agenticSecret ?? "",
80
81
  secure,
81
82
  data: app.data,
83
+ // #544: advisory, read-only element-instance resolution over the shared engine's wait-state
84
+ // read model, so the relay slice can key a captured agent session on the element INSTANCE it
85
+ // occupied (unambiguous across a looping / retried job), not just the static element id. A
86
+ // narrow closure — the agentic families never hold the engine handle itself.
87
+ resolveElementInstance: makeElementInstanceResolver(engine),
82
88
  log: app.log,
83
89
  });
84
90
  if (!secure) {
package/openapi.yaml CHANGED
@@ -643,6 +643,11 @@ components:
643
643
  elementId:
644
644
  type: string
645
645
  description: The BPMN element id (activity/task) the job was for, when still known (advisory).
646
+ elementInstanceKey:
647
+ type: string
648
+ description: The engine element-instance key the job's token occupied (#544) — the per-occupancy
649
+ handle, unambiguous across a looping / retried activity where many instances share one elementId.
650
+ Resolved from the job's element-instance wait-state; advisory, present when resolution landed.
646
651
  planKey:
647
652
  type: string
648
653
  description: The plan / epic key this job was part of (e.g. owner/repo#142), when still known (advisory).
@@ -749,6 +754,10 @@ components:
749
754
  elementId:
750
755
  type: string
751
756
  description: The BPMN element id, when still known (advisory).
757
+ elementInstanceKey:
758
+ type: string
759
+ description: The engine element-instance key the job's token occupied (#544) — per-occupancy,
760
+ unambiguous across a looping / retried activity; advisory, present when resolution landed.
752
761
  planKey:
753
762
  type: string
754
763
  description: The plan / epic key, when still known (advisory).
@@ -2802,6 +2811,13 @@ paths:
2802
2811
  schema:
2803
2812
  type: string
2804
2813
  description: Return only transcripts whose (still-known) correlation names this process instance.
2814
+ - name: elementInstanceKey
2815
+ in: query
2816
+ required: false
2817
+ schema:
2818
+ type: string
2819
+ description: Return only transcripts whose correlation names this engine element-instance key (#544) —
2820
+ resolves a session to one occupancy of a looping / retried activity, unlike the static elementId.
2805
2821
  - name: planKey
2806
2822
  in: query
2807
2823
  required: false
@@ -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
  }
@@ -46,6 +46,7 @@ export default defineOperation("listAgenticTranscripts", async ({ query, req },
46
46
  const filter: TranscriptFilter = {
47
47
  ...(query.jobKey !== undefined ? { jobKey: query.jobKey } : {}),
48
48
  ...(query.processInstanceKey !== undefined ? { processInstanceKey: query.processInstanceKey } : {}),
49
+ ...(query.elementInstanceKey !== undefined ? { elementInstanceKey: query.elementInstanceKey } : {}),
49
50
  ...(query.planKey !== undefined ? { planKey: query.planKey } : {}),
50
51
  ...(query.instance !== undefined ? { instance: query.instance } : {}),
51
52
  ...(query.since !== undefined ? { since: query.since } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.139.4",
3
+ "version": "0.141.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",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/agentic": "^0.4.0",
62
- "@nanobpm/urban": "^0.82.0",
62
+ "@nanobpm/urban": "^0.83.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2"
64
64
  },
65
65
  "devDependencies": {
@@ -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>