@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.
- package/CHANGELOG.md +12 -0
- package/app/agentic/channel.ts +15 -1
- package/app/agentic/correlation-store.test.ts +105 -12
- package/app/agentic/correlation-store.ts +64 -10
- package/app/agentic/correlation.test.ts +46 -0
- package/app/agentic/correlation.ts +41 -4
- package/app/agentic/element-instance.test.ts +111 -0
- package/app/agentic/element-instance.ts +97 -0
- package/app/agentic/families/relay.family.test.ts +121 -0
- package/app/agentic/families/relay.family.ts +113 -1
- package/app/agentic/registry.ts +17 -3
- package/app/agentic/transcript-read.test.ts +122 -2
- package/app/agentic/transcript-read.ts +63 -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/db/migrations/086_agentic_correlation_element_instance.sql +21 -0
- package/main.ts +6 -0
- package/openapi.yaml +16 -0
- package/operations/getAgenticTranscript.ts +12 -4
- package/operations/listAgenticTranscripts.ts +1 -0
- package/package.json +2 -2
- package/resources/processes/feature.bpmn +2 -0
|
@@ -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.
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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`
|
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);
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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.
|
|
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.
|
|
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="=" --- " + 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>
|