@nanobpm/nano-workforce 0.183.0 → 0.183.2

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.
@@ -21,6 +21,7 @@
21
21
  // is untouched — the agentic channel is the only new conversation; advisory semantics preserved (the
22
22
  // relay/transcript never hard-lock or gate a BPMN sequence flow).
23
23
  import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
24
+ import { parseStreamId } from "@nanobpm/agentic/emit";
24
25
  import type { Frame } from "@nanobpm/agentic/protocol";
25
26
  import { RELAY_FAMILY, RelayHub, type RelayHubOptions } from "@nanobpm/agentic/relay";
26
27
  import {
@@ -37,7 +38,7 @@ import {
37
38
  type TranscriptVocab,
38
39
  } from "@nanobpm/agentic/transcript";
39
40
  import type { Logger } from "@nanobpm/urban";
40
- import { currentCorrelation, type JobContext, type JobCorrelation, jobKeyOfStream } from "../correlation.ts";
41
+ import { currentCorrelation, type JobContext, type JobCorrelation } from "../correlation.ts";
41
42
  import { AgenticCorrelationStore } from "../correlation-store.ts";
42
43
  import type { ElementInstanceResolver } from "../element-instance.ts";
43
44
  import type { AgenticContext, AgenticFamily } from "../registry.ts";
@@ -194,7 +195,7 @@ interface StreamState {
194
195
  /** Set once an ephemeral stream has been flushed & completed (so it is not re-completed). */
195
196
  completed: boolean;
196
197
  /**
197
- * Set once a `job:<jobKey>` stream has been linked into the correlation registry (H6, #149), so
198
+ * Set once an instance-scoped job stream has been linked into the correlation registry (H6, #149), so
198
199
  * the link is attempted at most once per stream. It stays `false` while the producer's presence
199
200
  * instance is not yet resolvable (a register/produce race), so a later `produce` frame retries.
200
201
  */
@@ -208,14 +209,14 @@ interface StreamState {
208
209
  */
209
210
  createdAt: string;
210
211
  /**
211
- * The worker instance a `job:<jobKey>` stream was linked under (H6). Recorded so a stream's release
212
+ * The worker instance an instance-scoped job stream was linked under (H6). Recorded so a stream's release
212
213
  * (completion / disconnect) can tidy the {@link RelayTranscriptService.#jobStreamByInstance}
213
214
  * supersede index, and so the "one job at a time per worker" supersede rule can identify the
214
215
  * instance's prior job stream.
215
216
  */
216
217
  instance?: string;
217
218
  /**
218
- * The engine element-instance key this `job:<jobKey>` stream's job occupies (#544), once the
219
+ * The engine element-instance key this instance-scoped job stream's job occupies (#544), once the
219
220
  * asynchronous link-time resolution ({@link RelayTranscriptService.#resolveElementInstance}) lands.
220
221
  * Stashed on the stream so job completion can persist it even if the live correlation was already
221
222
  * enriched-and-released, and so a resolution that returns after completion can still be recognised.
@@ -275,7 +276,7 @@ export interface RelayTranscriptServiceOptions {
275
276
  readonly ensureSchema?: boolean;
276
277
  /**
277
278
  * The correlation write-side seam (H6, #149). When present, a first `produce` frame for a
278
- * `job:<jobKey>` stream links the producing worker instance → jobKey here, and the stream's
279
+ * instance-scoped job stream links the producing worker instance → jobKey here, and the stream's
279
280
  * completion / producer disconnect releases it. Defaults to the process-wide correlation registry
280
281
  * ({@link currentCorrelation}); absent (`() => undefined`) → no linking (advisory, never an error).
281
282
  */
@@ -317,7 +318,7 @@ export interface RelayTranscriptServiceOptions {
317
318
  */
318
319
  readonly correlationStore?: AgenticCorrelationStore;
319
320
  /**
320
- * Resolve the engine element-instance key a `job:<jobKey>` stream's job occupies (#544). Called
321
+ * Resolve the engine element-instance key an instance-scoped job stream's job occupies (#544). Called
321
322
  * fire-and-forget on the first `produce` (while the job's JOB park is still live), and its result
322
323
  * enriches the live correlation context / durable attribution so a captured session is keyed on the
323
324
  * element INSTANCE (unambiguous across a looping / retried job), not just the static element id.
@@ -357,7 +358,7 @@ export class RelayTranscriptService {
357
358
  readonly #log: Logger;
358
359
  readonly #streams = new Map<string, StreamState>();
359
360
  /**
360
- * The `job:<jobKey>` relay stream each worker instance is CURRENTLY relaying (H6, #149). A worker
361
+ * The instance-scoped job relay stream each worker instance is CURRENTLY relaying (H6, #149). A worker
361
362
  * relays every job it runs over one long-lived channel connection, one job at a time
362
363
  * (`../correlation.ts`), so that connection never disconnects between jobs — the disconnect-driven
363
364
  * `#reconcile` release never fires. This index lets a NEW job's first `produce` supersede the
@@ -609,17 +610,18 @@ export class RelayTranscriptService {
609
610
  }
610
611
 
611
612
  /**
612
- * H6 write-side (#149): on the first `produce` for a `job:<jobKey>` stream, link the producing
613
- * worker instance jobKey in the correlation registry, from data already crossing the wire (the
614
- * jobKey is decoded from the stream id; the instance is resolved from the producing connection).
615
- * That lights up the worker's `jobKeys` in the supply feed and repoints its drill stream at the
616
- * jobKey-scoped relay stream. Idempotent per stream; retries on a later frame while the producer's
617
- * presence instance is not yet resolvable (a register/produce race). Advisory never throws into
618
- * the frame handler.
613
+ * H6 write-side (#149): on the first `produce` for an instance-scoped job stream
614
+ * (`composeStreamId(instance, jobKey)`), link the producing worker instance jobKey in the
615
+ * correlation registry, from data already crossing the wire (the jobKey is decoded from the stream
616
+ * id via `parseStreamId`; the instance is resolved from the producing connection). That lights up
617
+ * the worker's `jobKeys` in the supply feed and repoints its drill stream at the instance-scoped
618
+ * relay stream the producer actually writes under (issue #738). Idempotent per stream; retries on a
619
+ * later frame while the producer's presence instance is not yet resolvable (a register/produce
620
+ * race). Advisory — never throws into the frame handler.
619
621
  */
620
622
  #link(stream: string, connectionId: string, state: StreamState): void {
621
623
  if (state.linked) return;
622
- const jobKey = jobKeyOfStream(stream);
624
+ const jobKey = parseStreamId(stream)?.stream;
623
625
  if (jobKey === undefined) return;
624
626
  const instance = this.#instanceForConnection(connectionId);
625
627
  if (instance === undefined || instance === "") return;
@@ -697,10 +699,11 @@ export class RelayTranscriptService {
697
699
  });
698
700
  }
699
701
 
700
- /** H6 write-side (#149): release a `job:<jobKey>` stream's correlation on completion / disconnect. */
702
+ /** H6 write-side (#149): release an instance-scoped job stream's (`composeStreamId(instance, jobKey)`)
703
+ * correlation on completion / disconnect. */
701
704
  #unlink(stream: string, state: StreamState): void {
702
705
  if (!state.linked) return;
703
- const jobKey = jobKeyOfStream(stream);
706
+ const jobKey = parseStreamId(stream)?.stream;
704
707
  if (jobKey === undefined) return;
705
708
  try {
706
709
  // Persist the completed job's durable worker attribution + (best-effort) engine context BEFORE
@@ -791,7 +794,7 @@ export class RelayTranscriptService {
791
794
  // relay-connection layer (the old presence heuristic, #690) conflates two independent liveness
792
795
  // signals (engine lease vs. WS connection); the authoritative one is the engine job-state the
793
796
  // poller already reconciles (#691).
794
- if (this.#resolveElementInstance !== undefined && jobKeyOfStream(stream) !== undefined) {
797
+ if (this.#resolveElementInstance !== undefined && parseStreamId(stream)?.stream !== undefined) {
795
798
  // Engine/poller-owned completion (#691): drop the dead producer so `#reconcile` does not
796
799
  // re-trigger on every subsequent frame, then reconcile THIS stream against the engine's view
797
800
  // of its job (fire-and-forget — the sync frame handler must not await an engine read). A
@@ -858,7 +861,7 @@ export class RelayTranscriptService {
858
861
  const streams: string[] = [];
859
862
  for (const [stream, state] of this.#streams) {
860
863
  if (state.completed) continue;
861
- if (jobKeyOfStream(stream) === undefined) continue;
864
+ if (parseStreamId(stream)?.stream === undefined) continue;
862
865
  // Include UNLINKED job streams too (#708). The disconnect path (#691) routes an unlinked
863
866
  // job stream through the engine reconcile and clears `state.producer`, so if the engine
864
867
  // reports "still parked" at disconnect (or the read faults transiently) and the worker never
@@ -871,7 +874,7 @@ export class RelayTranscriptService {
871
874
  }
872
875
 
873
876
  /**
874
- * Reconcile ONE `job:<jobKey>` stream against the engine's view of its job (the poller-owned
877
+ * Reconcile ONE instance-scoped job stream against the engine's view of its job (the poller-owned
875
878
  * completion authority, #691). Asks the engine read model (the {@link ElementInstanceResolver} the
876
879
  * #544 link path uses) whether the job is still parked: still parked → genuinely active, kept live;
877
880
  * gone → ended (a clean completion whose terminal lifecycle event was missed, or an unclean exit) →
@@ -895,7 +898,7 @@ export class RelayTranscriptService {
895
898
  if (resolve === undefined) return;
896
899
  const before = this.#streams.get(stream);
897
900
  if (before === undefined || before.completed) return;
898
- const jobKey = jobKeyOfStream(stream);
901
+ const jobKey = parseStreamId(stream)?.stream;
899
902
  if (jobKey === undefined) return;
900
903
  const processInstanceKey = this.#correlation()?.resolve?.(jobKey)?.processInstanceKey;
901
904
  let activeKey: string | undefined;
@@ -8,9 +8,16 @@ import { test } from "node:test";
8
8
  import { DatabaseSync } from "node:sqlite";
9
9
  import type { SqliteDb, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
10
10
  import { assert, assertEquals } from "#test-assert";
11
+ import { composeStreamId } from "@nanobpm/agentic/emit";
11
12
  import { AgenticCorrelationStore } from "./correlation-store.ts";
12
- import { CorrelationRegistry } from "./correlation.ts";
13
- import { correlationFieldsFor, listTranscripts, readTranscriptFrom } from "./transcript-read.ts";
13
+ import { CorrelationRegistry, jobStream } from "./correlation.ts";
14
+ import type { RelayTranscriptService } from "./families/relay.family.ts";
15
+ import { correlationFieldsFor, listTranscripts, readSingleTranscript, readTranscriptFrom } from "./transcript-read.ts";
16
+
17
+ /** The instance-scoped transcript stream id a job's terminal is stored under (issue #738); the jobKey
18
+ * is the stream part `parseStreamId` recovers. The worker instance is fixed here where it is immaterial
19
+ * to the assertion (the durable filters key off the recorded row's instance, not the stream's). */
20
+ const s = (jobKey: string): string => composeStreamId("w", jobKey);
14
21
 
15
22
  /** A read-only TranscriptStore double: list() returns the seeded metas; read() has no retained chunks. */
16
23
  function fakeStore(metas: TranscriptStream[]): TranscriptStore {
@@ -90,7 +97,7 @@ test("durable fallback: a released (past) job is attributed from the durable sto
90
97
  const durable = new AgenticCorrelationStore(memoryStore());
91
98
  durable.record({
92
99
  jobKey: "k1",
93
- stream: "job:k1",
100
+ stream: s("k1"),
94
101
  instance: "worker-A",
95
102
  identity: "gpu-box-7",
96
103
  host: "us-east-1a",
@@ -99,7 +106,7 @@ test("durable fallback: a released (past) job is attributed from the durable sto
99
106
  completedAt: mid,
100
107
  });
101
108
 
102
- const fields = correlationFieldsFor("job:k1", undefined, durable);
109
+ const fields = correlationFieldsFor(s("k1"), undefined, durable);
103
110
  assertEquals(fields.jobKey, "k1");
104
111
  assertEquals(fields.instance, "worker-A");
105
112
  assertEquals(fields.identity, "gpu-box-7");
@@ -111,16 +118,16 @@ test("durable fallback: a released (past) job is attributed from the durable sto
111
118
  test("#544: the durable element-instance key surfaces on the read projection and its filter", () => {
112
119
  const durable = new AgenticCorrelationStore(memoryStore());
113
120
  // Two iterations of the SAME static element (`agent`) — distinct element instances, distinct jobKeys.
114
- durable.record({ jobKey: "k1", stream: "job:k1", instance: "worker-A", elementId: "agent", elementInstanceKey: "ei-1", completedAt: early });
115
- durable.record({ jobKey: "k2", stream: "job:k2", instance: "worker-A", elementId: "agent", elementInstanceKey: "ei-2", completedAt: late });
121
+ durable.record({ jobKey: "k1", stream: s("k1"), instance: "worker-A", elementId: "agent", elementInstanceKey: "ei-1", completedAt: early });
122
+ durable.record({ jobKey: "k2", stream: s("k2"), instance: "worker-A", elementId: "agent", elementInstanceKey: "ei-2", completedAt: late });
116
123
 
117
124
  // The key surfaces on the correlation fields (durable fallback, live registry empty).
118
- assertEquals(correlationFieldsFor("job:k1", undefined, durable).elementInstanceKey, "ei-1");
125
+ assertEquals(correlationFieldsFor(s("k1"), undefined, durable).elementInstanceKey, "ei-1");
119
126
 
120
- const store = fakeStore([meta("job:k1", early), meta("job:k2", late)]);
127
+ const store = fakeStore([meta(s("k1"), early), meta(s("k2"), late)]);
121
128
  // The elementInstanceKey filter resolves a session to ONE occupancy, where the elementId cannot.
122
129
  const out = listTranscripts(store, undefined, { elementInstanceKey: "ei-2" }, durable);
123
- assertEquals(out.map((t) => t.stream), ["job:k2"], "only the ei-2 occupancy matches");
130
+ assertEquals(out.map((t) => t.stream), [s("k2")], "only the ei-2 occupancy matches");
124
131
  assertEquals(out[0].elementInstanceKey, "ei-2", "the projection carries the element-instance key");
125
132
  });
126
133
 
@@ -129,22 +136,22 @@ test("#544: the live correlation's element-instance key takes precedence over th
129
136
  registry.link("worker-A", "k1", { elementId: "agent" });
130
137
  registry.attachElementInstance("k1", "ei-live");
131
138
  const durable = new AgenticCorrelationStore(memoryStore());
132
- durable.record({ jobKey: "k1", stream: "job:k1", instance: "worker-A", elementInstanceKey: "ei-old", completedAt: mid });
139
+ durable.record({ jobKey: "k1", stream: s("k1"), instance: "worker-A", elementInstanceKey: "ei-old", completedAt: mid });
133
140
 
134
- assertEquals(correlationFieldsFor("job:k1", registry, durable).elementInstanceKey, "ei-live");
141
+ assertEquals(correlationFieldsFor(s("k1"), registry, durable).elementInstanceKey, "ei-live");
135
142
  });
136
143
 
137
144
  test("listTranscripts: the instance filter returns only sessions the durable store attributes to that worker", () => {
138
- const store = fakeStore([meta("job:k1", early), meta("job:k2", mid), meta("job:k3", late)]);
145
+ const store = fakeStore([meta(s("k1"), early), meta(s("k2"), mid), meta(s("k3"), late)]);
139
146
  const durable = new AgenticCorrelationStore(memoryStore());
140
- durable.record({ jobKey: "k1", stream: "job:k1", instance: "worker-A", completedAt: early });
141
- durable.record({ jobKey: "k2", stream: "job:k2", instance: "worker-B", completedAt: mid });
142
- durable.record({ jobKey: "k3", stream: "job:k3", instance: "worker-A", completedAt: late });
147
+ durable.record({ jobKey: "k1", stream: s("k1"), instance: "worker-A", completedAt: early });
148
+ durable.record({ jobKey: "k2", stream: s("k2"), instance: "worker-B", completedAt: mid });
149
+ durable.record({ jobKey: "k3", stream: s("k3"), instance: "worker-A", completedAt: late });
143
150
 
144
151
  const out = listTranscripts(store, undefined, { instance: "worker-A" }, durable);
145
152
  assertEquals(
146
153
  out.map((t) => t.stream),
147
- ["job:k3", "job:k1"],
154
+ [s("k3"), s("k1")],
148
155
  "only worker-A's sessions, newest-first",
149
156
  );
150
157
  assert(out.every((t) => t.instance === "worker-A"), "each row is attributed to worker-A");
@@ -183,7 +190,7 @@ test("readTranscriptFrom: falls back to the live ring when the durable store has
183
190
  { offset: 0, chunk: "hello " },
184
191
  { offset: 1, chunk: "world" },
185
192
  ]);
186
- const out = readTranscriptFrom("job:live1", 0, getStore(undefined), undefined, undefined, {
193
+ const out = readTranscriptFrom(s("live1"), 0, getStore(undefined), undefined, undefined, {
187
194
  ring,
188
195
  createdAt: mid,
189
196
  });
@@ -242,3 +249,87 @@ test("readTranscriptFrom: returns undefined when neither the store nor a live ri
242
249
  const out = readTranscriptFrom("job:gone", 0, getStore(undefined), undefined, undefined, undefined);
243
250
  assertEquals(out, undefined);
244
251
  });
252
+
253
+ // --- #738: the Stage-0 `job:<jobKey>` transcript URL still resolves after the data-plane moved to the
254
+ // instance-scoped `composeStreamId(instance, jobKey)` stream ---
255
+ //
256
+ // The Explorer Stage-0 `transcriptUrl` (app/agentic/transcript-url.ts, #543) addresses a job by the bare
257
+ // `job:<jobKey>` alias — the worker cannot know its own instance at output-mapping time. The transcript is
258
+ // now STORED under the instance-scoped id, so a Stage-0 read must map the alias back to that id (via the
259
+ // live registry while linked, else the durable store) or it would 404 the URL it just emitted.
260
+
261
+ /** A keyed TranscriptStore double: `get`/`since` resolve only for the exact stream id seeded (unlike
262
+ * {@link getStore}, which returns its row for ANY id) — so a test can prove the alias was RESOLVED. */
263
+ function keyedStore(rows: Record<string, { meta: TranscriptStream; entries: { offset: number; chunk: string }[] }>): TranscriptStore {
264
+ return {
265
+ get: (stream: string) => rows[stream]?.meta,
266
+ since: (stream: string, from: number) => ({
267
+ entries: (rows[stream]?.entries ?? []).filter((e) => e.offset >= from),
268
+ gap: false,
269
+ nextOffset: rows[stream]?.meta.nextOffset ?? 0,
270
+ }),
271
+ list: () => Object.values(rows).map((r) => r.meta),
272
+ read: () => [],
273
+ } as unknown as TranscriptStore;
274
+ }
275
+
276
+ /** A RelayTranscriptService double exposing only the surface {@link readSingleTranscript} reads. */
277
+ function fakeService(
278
+ store: TranscriptStore | undefined,
279
+ correlationStore: AgenticCorrelationStore | undefined,
280
+ rings: Record<string, { ring: TranscriptRing; createdAt: string }> = {},
281
+ ): RelayTranscriptService {
282
+ return {
283
+ store,
284
+ correlationStore,
285
+ liveFallback: (stream: string) => rings[stream],
286
+ } as unknown as RelayTranscriptService;
287
+ }
288
+
289
+ test("readSingleTranscript: a Stage-0 job:<jobKey> alias resolves to the instance-scoped stored stream via the durable store (#738)", () => {
290
+ const durable = new AgenticCorrelationStore(memoryStore());
291
+ durable.record({ jobKey: "j1", stream: s("j1"), instance: "w", planKey: "acme/repo#1", completedAt: mid });
292
+ const store = keyedStore({
293
+ [s("j1")]: {
294
+ meta: { stream: s("j1"), lifecycle: "ephemeral", status: "completed", createdAt: early, completedAt: late, nextOffset: 1 },
295
+ entries: [{ offset: 0, chunk: "durable-bytes" }],
296
+ },
297
+ });
298
+ const res = readSingleTranscript(jobStream("j1"), 0, fakeService(store, durable), undefined);
299
+ assertEquals(res.status, 200, "the alias resolves to the instance-scoped row, not a 404");
300
+ assert(res.status === 200);
301
+ assertEquals(res.body.stream, s("j1"), "served under the instance-scoped id the bytes are stored on");
302
+ assertEquals(res.body.entries.map((e) => e.chunk).join(""), "durable-bytes");
303
+ assertEquals(res.body.jobKey, "j1");
304
+ assertEquals(res.body.planKey, "acme/repo#1", "durable attribution still surfaces through the alias read");
305
+ });
306
+
307
+ test("readSingleTranscript: a Stage-0 job:<jobKey> alias resolves via the LIVE registry while the job is still linked (#738)", () => {
308
+ const registry = new CorrelationRegistry();
309
+ registry.link("w", "j2", { planKey: "acme/repo#2" });
310
+ const store = keyedStore({
311
+ [s("j2")]: {
312
+ meta: { stream: s("j2"), lifecycle: "ephemeral", status: "open", createdAt: mid, nextOffset: 1 },
313
+ entries: [{ offset: 0, chunk: "live-row" }],
314
+ },
315
+ });
316
+ const res = readSingleTranscript(jobStream("j2"), 0, fakeService(store, undefined), registry);
317
+ assertEquals(res.status, 200);
318
+ assert(res.status === 200);
319
+ assertEquals(res.body.entries.map((e) => e.chunk).join(""), "live-row");
320
+ assertEquals(res.body.planKey, "acme/repo#2");
321
+ });
322
+
323
+ test("readSingleTranscript: an unresolvable job:<jobKey> alias still 404s (no correlation anywhere)", () => {
324
+ const res = readSingleTranscript(jobStream("ghost"), 0, fakeService(keyedStore({}), undefined), undefined);
325
+ assertEquals(res.status, 404);
326
+ });
327
+
328
+ test("correlationFieldsFor: decodes the jobKey from a bare job:<jobKey> Stage-0 alias too (#738)", () => {
329
+ const durable = new AgenticCorrelationStore(memoryStore());
330
+ durable.record({ jobKey: "j3", stream: s("j3"), instance: "worker-Z", planKey: "acme/repo#3", completedAt: mid });
331
+ const fields = correlationFieldsFor(jobStream("j3"), undefined, durable);
332
+ assertEquals(fields.jobKey, "j3", "the alias decodes its jobKey rather than yielding an empty projection");
333
+ assertEquals(fields.instance, "worker-Z");
334
+ assertEquals(fields.planKey, "acme/repo#3");
335
+ });
@@ -9,14 +9,17 @@
9
9
  // Correlation is BEST-EFFORT and advisory: the correlation registry is in-memory and only holds
10
10
  // currently-linked jobs, so a completed session's process-instance / plan context is present only
11
11
  // while the job is still live. The jobKey itself is always recoverable — it is encoded in the stream
12
- // id (`job:<jobKey>`), so a past session is never anonymous even once its correlation has been released.
12
+ // id (`composeStreamId(instance, jobKey)`, decoded with `parseStreamId`), so a past session is never
13
+ // anonymous even once its correlation has been released.
13
14
  //
14
15
  // Pure and side-effect-free apart from reading the store: no I/O beyond the injected store, so it is
15
16
  // unit-testable on the injected env (Node, no browser), and never touches the engine or a BPMN flow.
16
17
 
18
+ import { parseStreamId } from "@nanobpm/agentic/emit";
17
19
  import type { TranscriptChunk, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
18
20
  import type { AgenticTranscript, AgenticTranscriptData, ErrorBody } from "../../nano-generated/api-io.d.ts";
19
- import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
21
+ import type { CorrelationRegistry } from "./correlation.ts";
22
+ import { jobKeyOfJobStream } from "./correlation.ts";
20
23
  import type { AgenticCorrelationStore } from "./correlation-store.ts";
21
24
  import type { RelayTranscriptService } from "./families/relay.family.ts";
22
25
  import { utf8ByteLength } from "./transcript-events.ts";
@@ -46,8 +49,10 @@ interface CorrelationFields {
46
49
  }
47
50
 
48
51
  /**
49
- * Resolve a stream id to its correlation fields. The jobKey is always decoded from a `job:<jobKey>`
50
- * stream id. Engine context (process instance / plan) + worker attribution (instance / identity /
52
+ * Resolve a stream id to its correlation fields. The jobKey is decoded from the instance-scoped
53
+ * stream id (`composeStreamId(instance, jobKey)`) via {@link parseStreamId}, falling back to the bare
54
+ * `job:<jobKey>` Stage-0 alias ({@link jobKeyOfJobStream}) so a session addressed by either scheme stays
55
+ * attributable. Engine context (process instance / plan) + worker attribution (instance / identity /
51
56
  * host) come from the LIVE registry while the job is still linked, and fall back to the DURABLE store
52
57
  * (`AgenticCorrelationStore`) once the job has completed and its live correlation was released — so a
53
58
  * PAST session stays attributable to its worker after the worker exits or the process restarts.
@@ -58,7 +63,7 @@ export function correlationFieldsFor(
58
63
  correlation: CorrelationRegistry | undefined,
59
64
  durable?: AgenticCorrelationStore | undefined,
60
65
  ): CorrelationFields {
61
- const jobKey = jobKeyOfStream(stream);
66
+ const jobKey = parseStreamId(stream)?.stream ?? jobKeyOfJobStream(stream);
62
67
  if (jobKey === undefined) return {};
63
68
  const fields: CorrelationFields = { jobKey };
64
69
  const context = correlation?.resolve(jobKey);
@@ -251,6 +256,30 @@ export type SingleTranscriptResult =
251
256
  | { status: 400; body: ErrorBody }
252
257
  | { status: 404; body: ErrorBody };
253
258
 
259
+ /**
260
+ * Resolve a requested stream id to the instance-scoped id the transcript is actually STORED under.
261
+ *
262
+ * The data plane keys every job transcript by `composeStreamId(instance, jobKey)` (issue #738), but the
263
+ * Explorer Stage-0 `transcriptUrl` (`app/agentic/transcript-url.ts`, #543) still addresses a job by the
264
+ * bare `job:<jobKey>` alias — the completing worker cannot know its own instance at output-mapping time.
265
+ * So a Stage-0 read arrives keyed by `job:<jobKey>` and would MISS the store (and 404) unless it is first
266
+ * mapped back to the instance-scoped id. The jobKey → instance-scoped stream is recovered from the LIVE
267
+ * correlation registry while the job is still linked, else from the DURABLE store (which persists the
268
+ * instance-scoped `stream` keyed by jobKey at completion). An already-instance-scoped id, a non-job
269
+ * stream, or an unknown jobKey is returned unchanged (the caller then reads it as-is, 404-ing if absent).
270
+ */
271
+ export function canonicalStreamFor(
272
+ stream: string,
273
+ correlation: CorrelationRegistry | undefined,
274
+ durable: AgenticCorrelationStore | undefined,
275
+ ): string {
276
+ // Already instance-scoped (or a non-alias id) — nothing to resolve.
277
+ if (parseStreamId(stream) !== undefined) return stream;
278
+ const jobKey = jobKeyOfJobStream(stream);
279
+ if (jobKey === undefined) return stream;
280
+ return correlation?.resolve(jobKey)?.stream ?? durable?.get(jobKey)?.stream ?? stream;
281
+ }
282
+
254
283
  /**
255
284
  * The ONE canonical single-stream transcript read, shared by BOTH routes that serve it (#744):
256
285
  * `GET /agentic/transcripts?stream=<id>&from=<n>` (the proxy-safe QUERY form the cockpit clients
@@ -259,6 +288,10 @@ export type SingleTranscriptResult =
259
288
  * `GET /agentic/transcripts/{stream}` (the legacy path form the worker-emitted `transcriptUrl`
260
289
  * resolves — safe there because `job:<jobKey>` ids structurally never contain a slash). One
261
290
  * implementation so the two addressings can never answer differently for the same stream/from.
291
+ *
292
+ * A Stage-0 `job:<jobKey>` alias is first resolved to the instance-scoped id the bytes are stored under
293
+ * ({@link canonicalStreamFor}), so both the durable store read AND the live-ring fallback address the
294
+ * stream the producer actually wrote (issue #738) rather than 404-ing the alias.
262
295
  */
263
296
  export function readSingleTranscript(
264
297
  stream: string,
@@ -274,13 +307,14 @@ export function readSingleTranscript(
274
307
  // No relay/transcript service mounted at all - nothing to replay.
275
308
  return { status: 404, body: { error: "no transcript for stream" } };
276
309
  }
310
+ const readStream = canonicalStreamFor(stream, correlation, service.correlationStore);
277
311
  const data = readTranscriptFrom(
278
- stream,
312
+ readStream,
279
313
  offset,
280
314
  service.store,
281
315
  correlation,
282
316
  service.correlationStore,
283
- service.liveFallback(stream),
317
+ service.liveFallback(readStream),
284
318
  );
285
319
  if (data === undefined) {
286
320
  return { status: 404, body: { error: "no transcript for stream" } };
package/app/contracts.ts CHANGED
@@ -468,7 +468,7 @@ export const WIRE_CONTRACTS = {
468
468
  name: "transcript.lifecycleClose",
469
469
  owner: "app/agentic/families/relay.family.ts",
470
470
  semantics:
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.",
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's instance-scoped `composeStreamId(instance, jobKey)` relay stream (issue #738) 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
474
  "transcript.readUrl": {