@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 +6 -0
- package/app/agentic/families/relay.family.test.ts +31 -0
- package/app/agentic/families/relay.family.ts +29 -1
- package/app/agentic/transcript-read.test.ts +95 -2
- package/app/agentic/transcript-read.ts +52 -21
- package/app/agentic/transcript-url.test.ts +35 -0
- package/app/agentic/transcript-url.ts +54 -0
- package/app/deliveryGraphCompiler.test.ts +27 -0
- package/app/deliveryGraphCompiler.ts +14 -0
- package/app/deliveryGraphDeploy.test.ts +79 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/feature.ts +6 -0
- package/operations/getAgenticTranscript.ts +12 -4
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +2 -0
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.
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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`
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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.
|
|
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="=" --- " + task.prompt + (if (baseBranchBrief = null) then "" else baseBranchBrief) + (if (resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then "" else (" --- **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: " + string join(resolvedArtifacts[item != null], " "))) + (if (customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + 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>
|