@nanobpm/nano-workforce 0.111.0 → 0.112.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 +14 -0
- package/app/deliveryGraph.ts +46 -0
- package/app/deliveryGraphCompiler.test.ts +275 -0
- package/app/deliveryGraphCompiler.ts +487 -0
- package/app/escalationTaxonomy.test.ts +1 -1
- package/app/escalationTaxonomy.ts +4 -2
- package/app/github.test.ts +225 -1
- package/app/github.ts +102 -2
- package/app/planFanoutCleanTerminal.test.ts +40 -0
- package/app/pollUserTasks.test.ts +208 -0
- package/app/readiness.test.ts +2 -0
- package/app/readiness.ts +3 -1
- package/app/service.ts +216 -198
- package/app/userTasks.test.ts +20 -2
- package/app/userTasks.ts +11 -4
- package/nano.app.json +4 -0
- package/openapi.yaml +248 -0
- package/operations/compileDeliveryGraph.test.ts +60 -0
- package/operations/compileDeliveryGraph.ts +35 -0
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +161 -135
- package/workers/record-wave/worker.test.ts +181 -0
- package/workers/record-wave/worker.ts +44 -11
- package/workers/record-wave-escalation/worker.test.ts +95 -0
- package/workers/record-wave-escalation/worker.ts +69 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// nano-workforce — the TRUSTED, DETERMINISTIC compiler for an agent-authored delivery graph
|
|
2
|
+
// (ADR 0005, slice S1). It is the fast, safe INNER LOOP a co-designing agent hammers: given an
|
|
3
|
+
// agent-authored `DeliveryGraph` (the JSON contract from slice S0), it VALIDATES (the pure
|
|
4
|
+
// `validateDeliveryGraph` semantic check), then COMPILES it to a native artifact, and RENDERS a
|
|
5
|
+
// preview — but it NEVER deploys or dispatches anything (Decision 5/6: `compile` and `start` are
|
|
6
|
+
// SEPARATE doors; there is deliberately no `dryRun` flag on the start door). Being side-effect-free,
|
|
7
|
+
// it is callable repeatedly while the agent iterates JSON → compile → fix.
|
|
8
|
+
//
|
|
9
|
+
// Two invariants make it TRUSTED (Decision 1/2 — the closed node vocabulary is the trust boundary):
|
|
10
|
+
//
|
|
11
|
+
// • It is human-written and DETERMINISTIC: the same input JSON always produces byte-identical
|
|
12
|
+
// output (BPMN, diagram, resolved graph). Nodes/edges are sorted by id and every generated id is
|
|
13
|
+
// assigned positionally, so there is no map-iteration or timestamp nondeterminism.
|
|
14
|
+
// • It only ever instantiates ALLOWLISTED node kinds. The `compileNode` switch is exhaustive over
|
|
15
|
+
// the closed `DeliveryNodeKind` union with a `never` default, so the compiler CANNOT emit a
|
|
16
|
+
// construct for a non-allowlisted kind — a new kind fails `tsc` until it is deliberately handled.
|
|
17
|
+
// `validateDeliveryGraph` is the runtime guard (an unknown kind is rejected before compilation);
|
|
18
|
+
// the exhaustive switch is the compile-time guarantee.
|
|
19
|
+
//
|
|
20
|
+
// Compile-to-native (the first cut, Decision 6): each node maps to an engine-native call
|
|
21
|
+
// activity / sub-process (a `wait` reuses the real `readiness-gate`; `agent`/`connector` target
|
|
22
|
+
// forward-declared bodies S4 binds) and each edge becomes a native sequence flow, with explicit
|
|
23
|
+
// parallel gateways for genuine fan-out (>1 downstream) and fan-in (>1 upstream). This slice targets
|
|
24
|
+
// the WIRING/SHAPE — the concrete node bodies land in S4.
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
CompileDeliveryGraphErrors,
|
|
28
|
+
CompileDeliveryGraphResult,
|
|
29
|
+
DeliveryFact,
|
|
30
|
+
DeliveryGraph,
|
|
31
|
+
DeliveryHumanStop,
|
|
32
|
+
DeliveryNode,
|
|
33
|
+
DeliverySideEffect,
|
|
34
|
+
ResolvedDeliveryEdge,
|
|
35
|
+
ResolvedDeliveryNode,
|
|
36
|
+
} from "../nano-generated/api-io.d.ts";
|
|
37
|
+
import {
|
|
38
|
+
type DeliveryGraphError,
|
|
39
|
+
deliveryNodeFacts,
|
|
40
|
+
resolveDeliveryFrom,
|
|
41
|
+
validateDeliveryGraph,
|
|
42
|
+
} from "./deliveryGraph.ts";
|
|
43
|
+
|
|
44
|
+
/** The engine-native sub-process each non-human node kind delegates to (Decision 2 — the graph
|
|
45
|
+
* SCHEDULES, it does not re-implement execution). `wait` reuses the REAL `readiness-gate` process
|
|
46
|
+
* (`resources/processes/readiness-gate.bpmn`); `agent`/`connector` target forward-declared bodies
|
|
47
|
+
* that slice S4 binds to their concrete implementations. `human` has NO called element — it compiles
|
|
48
|
+
* to a native user task, not a call activity. Kept as the single source of truth so the compiler and
|
|
49
|
+
* the resolved-preview agree on the target. */
|
|
50
|
+
const CALLED_ELEMENT: Record<Exclude<DeliveryNode["kind"], "human">, string> = {
|
|
51
|
+
agent: "delivery-node-agent",
|
|
52
|
+
wait: "readiness-gate",
|
|
53
|
+
connector: "delivery-node-connector",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A never-reached exhaustiveness guard: `compileNode`'s `switch` covers every allowlisted kind, so
|
|
57
|
+
* the closed union narrows to `never` here. If a future kind is added to the vocabulary without a
|
|
58
|
+
* compiler arm, `tsc` flags this call — the compile-time half of the trust bound. */
|
|
59
|
+
function assertNever(value: never, context: string): never {
|
|
60
|
+
throw new Error(`${context}: unreachable — non-allowlisted delivery node kind ${JSON.stringify(value)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Escape a string for use as XML text / attribute content. Deterministic and total. */
|
|
64
|
+
function escapeXml(value: string): string {
|
|
65
|
+
return value
|
|
66
|
+
.replace(/&/g, "&")
|
|
67
|
+
.replace(/</g, "<")
|
|
68
|
+
.replace(/>/g, ">")
|
|
69
|
+
.replace(/"/g, """)
|
|
70
|
+
.replace(/'/g, "'");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Escape a string for use inside a mermaid quoted label. Mermaid uses `#` HTML-entity escapes; a
|
|
74
|
+
* double quote inside a `"…"` label must become `#quot;` so the label stays well-formed. */
|
|
75
|
+
function escapeMermaid(value: string): string {
|
|
76
|
+
return value.replace(/"/g, "#quot;").replace(/\n/g, " ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A node's typed emits, normalised to a stable array (absent → `[]`). */
|
|
80
|
+
function normaliseEmits(node: DeliveryNode): DeliveryFact[] {
|
|
81
|
+
return Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Locale-independent, byte-stable string ordering: compares by UTF-16 code unit, so the sort is
|
|
85
|
+
* identical across host locales (unlike `localeCompare`, whose collation varies by runtime locale for
|
|
86
|
+
* non-ASCII ids). This keeps the compiler's "byte-identical across environments" determinism guarantee
|
|
87
|
+
* strict. Returns -1 / 0 / 1. */
|
|
88
|
+
function byCodeUnit(a: string, b: string): number {
|
|
89
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One sequence flow in the compiled process — `source`/`target` are element ids, `name` an optional
|
|
93
|
+
* (fact) label. */
|
|
94
|
+
interface Flow {
|
|
95
|
+
id: string;
|
|
96
|
+
source: string;
|
|
97
|
+
target: string;
|
|
98
|
+
name?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A compiled node's structural fixtures: its own BPMN `element` id, and — when it has >1 downstream
|
|
102
|
+
* or >1 upstream — the parallel fork/join gateway that fans its flow out/in. `entry` is the id
|
|
103
|
+
* upstream flows target (the join, else the element); `exit` is the id downstream flows leave from
|
|
104
|
+
* (the fork, else the element). */
|
|
105
|
+
interface NodeWiring {
|
|
106
|
+
node: DeliveryNode;
|
|
107
|
+
element: string;
|
|
108
|
+
forkGateway?: string;
|
|
109
|
+
joinGateway?: string;
|
|
110
|
+
entry: string;
|
|
111
|
+
exit: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fetch a key that MUST be present (every node id was registered in the map above). Returns the
|
|
115
|
+
* value without a type assertion, throwing on the impossible missing case. */
|
|
116
|
+
function mustGet<K, V>(map: ReadonlyMap<K, V>, key: K): V {
|
|
117
|
+
const value = map.get(key);
|
|
118
|
+
if (value === undefined) throw new Error(`compileDeliveryGraph: missing map entry for ${String(key)}`);
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate + compile a delivery graph into a PURE preview (ADR 0005 slice S1). Returns
|
|
124
|
+
* `{ ok:true, diagram, bpmn, resolved, humanNodes, sideEffects }` for a well-formed graph, or
|
|
125
|
+
* `{ ok:false, errors }` (each error path-qualified) for a malformed one. NEVER deploys, dispatches,
|
|
126
|
+
* or mutates anything — safe to call repeatedly. Deterministic: identical input JSON yields
|
|
127
|
+
* byte-identical output.
|
|
128
|
+
*/
|
|
129
|
+
export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult | CompileDeliveryGraphErrors {
|
|
130
|
+
const validationErrors: DeliveryGraphError[] = validateDeliveryGraph(graph);
|
|
131
|
+
if (validationErrors.length > 0) {
|
|
132
|
+
// Forward every semantic failure verbatim as a wire `{ path, message }` (the stable `code` stays
|
|
133
|
+
// server-side). Nothing is compiled — the agent fixes the exact offending input and re-compiles.
|
|
134
|
+
return { ok: false, errors: validationErrors.map(({ path, message }) => ({ path, message })) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The graph passed both the OpenAPI shape gate (at the edge) and the semantic validator, so it is
|
|
138
|
+
// safe to narrow to the typed contract. Every field below is well-formed by construction.
|
|
139
|
+
// biome-ignore lint/plugin: validated external body narrowed to its contract after validateDeliveryGraph
|
|
140
|
+
const typed = graph as DeliveryGraph;
|
|
141
|
+
const nodes = [...typed.nodes].sort((a, b) => byCodeUnit(a.id, b.id));
|
|
142
|
+
const edges = Array.isArray(typed.edges) ? typed.edges : [];
|
|
143
|
+
|
|
144
|
+
// Resolve every edge's `from` endpoint against the SAME node/fact map the validator checked (shared
|
|
145
|
+
// helper — no drift), then sort edges deterministically by (consumer, producer, fact).
|
|
146
|
+
const nodeFacts = deliveryNodeFacts(typed);
|
|
147
|
+
const resolvedEdges: ResolvedDeliveryEdge[] = edges
|
|
148
|
+
.map((edge) => {
|
|
149
|
+
const { nodeId, fact } = resolveDeliveryFrom(edge.from, nodeFacts);
|
|
150
|
+
const resolved: ResolvedDeliveryEdge = { from: edge.from, to: edge.to, fromNode: nodeId };
|
|
151
|
+
return fact !== undefined ? { ...resolved, fromFact: fact } : resolved;
|
|
152
|
+
})
|
|
153
|
+
.sort(
|
|
154
|
+
(a, b) =>
|
|
155
|
+
byCodeUnit(a.to, b.to) ||
|
|
156
|
+
byCodeUnit(a.fromNode, b.fromNode) ||
|
|
157
|
+
byCodeUnit(a.fromFact ?? "", b.fromFact ?? ""),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Per-node producer/consumer adjacency (by node id), each sorted + de-duplicated for determinism.
|
|
161
|
+
const producersById = new Map<string, string[]>();
|
|
162
|
+
const consumersById = new Map<string, string[]>();
|
|
163
|
+
for (const node of nodes) {
|
|
164
|
+
producersById.set(node.id, []);
|
|
165
|
+
consumersById.set(node.id, []);
|
|
166
|
+
}
|
|
167
|
+
for (const edge of resolvedEdges) {
|
|
168
|
+
pushUnique(producersById.get(edge.to), edge.fromNode);
|
|
169
|
+
pushUnique(consumersById.get(edge.fromNode), edge.to);
|
|
170
|
+
}
|
|
171
|
+
for (const list of producersById.values()) list.sort(byCodeUnit);
|
|
172
|
+
for (const list of consumersById.values()) list.sort(byCodeUnit);
|
|
173
|
+
|
|
174
|
+
// Assign the deterministic BPMN element id per node (`n0`, `n1`, … in sorted order) plus the
|
|
175
|
+
// fork/join gateway ids (`gwf<i>` / `gwj<i>`) any fan-out/fan-in node needs.
|
|
176
|
+
const elementById = new Map<string, string>();
|
|
177
|
+
const wirings: NodeWiring[] = [];
|
|
178
|
+
const wiringById = new Map<string, NodeWiring>();
|
|
179
|
+
let forkSeq = 0;
|
|
180
|
+
let joinSeq = 0;
|
|
181
|
+
nodes.forEach((node, i) => {
|
|
182
|
+
const element = `n${i}`;
|
|
183
|
+
elementById.set(node.id, element);
|
|
184
|
+
const consumers = consumersById.get(node.id) ?? [];
|
|
185
|
+
const producers = producersById.get(node.id) ?? [];
|
|
186
|
+
const forkGateway = consumers.length > 1 ? `gwf${forkSeq++}` : undefined;
|
|
187
|
+
const joinGateway = producers.length > 1 ? `gwj${joinSeq++}` : undefined;
|
|
188
|
+
const wiring: NodeWiring = {
|
|
189
|
+
node,
|
|
190
|
+
element,
|
|
191
|
+
forkGateway,
|
|
192
|
+
joinGateway,
|
|
193
|
+
entry: joinGateway ?? element,
|
|
194
|
+
exit: forkGateway ?? element,
|
|
195
|
+
};
|
|
196
|
+
wirings.push(wiring);
|
|
197
|
+
wiringById.set(node.id, wiring);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const roots = nodes.filter((n) => (producersById.get(n.id) ?? []).length === 0);
|
|
201
|
+
const leaves = nodes.filter((n) => (consumersById.get(n.id) ?? []).length === 0);
|
|
202
|
+
const startForkGateway = roots.length > 1 ? "gwf_start" : undefined;
|
|
203
|
+
const endJoinGateway = leaves.length > 1 ? "gwj_end" : undefined;
|
|
204
|
+
|
|
205
|
+
// ── Build the flow list in a DETERMINISTIC order, then assign `f0…` ids positionally ────────────
|
|
206
|
+
const flows: Omit<Flow, "id">[] = [];
|
|
207
|
+
// 1. Start → root(s).
|
|
208
|
+
if (roots.length === 1) {
|
|
209
|
+
flows.push({ source: "Start", target: mustGet(wiringById, roots[0].id).entry });
|
|
210
|
+
} else if (roots.length > 1) {
|
|
211
|
+
flows.push({ source: "Start", target: "gwf_start" });
|
|
212
|
+
for (const root of roots) {
|
|
213
|
+
flows.push({ source: "gwf_start", target: mustGet(wiringById, root.id).entry });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// 2. Structural fork flows (node → its fork gateway).
|
|
217
|
+
for (const w of wirings) if (w.forkGateway) flows.push({ source: w.element, target: w.forkGateway });
|
|
218
|
+
// 3. Structural join flows (join gateway → node).
|
|
219
|
+
for (const w of wirings) if (w.joinGateway) flows.push({ source: w.joinGateway, target: w.element });
|
|
220
|
+
// 4. Edge flows: producer.exit → consumer.entry, labelled with the referenced fact(s) when
|
|
221
|
+
// qualified. Collapse edges sharing the same (fromNode → to) endpoints into ONE sequence flow:
|
|
222
|
+
// `producersById`/`consumersById` (hence the fork/join gateways) are de-duplicated by node id, so
|
|
223
|
+
// two fact-qualified edges between the same pair (e.g. `a.x -> b` and `a.y -> b`) would otherwise
|
|
224
|
+
// emit parallel flows between endpoints with no diverging gateway — invalid BPMN that schedules
|
|
225
|
+
// the consumer more than once. `resolvedEdges` is already sorted by (to, fromNode, fromFact), so
|
|
226
|
+
// same-endpoint edges are contiguous and their fact labels accumulate in deterministic order.
|
|
227
|
+
const collapsedEdges: { fromNode: string; to: string; facts: string[] }[] = [];
|
|
228
|
+
for (const edge of resolvedEdges) {
|
|
229
|
+
const last = collapsedEdges[collapsedEdges.length - 1];
|
|
230
|
+
if (last && last.fromNode === edge.fromNode && last.to === edge.to) {
|
|
231
|
+
if (edge.fromFact !== undefined && !last.facts.includes(edge.fromFact)) last.facts.push(edge.fromFact);
|
|
232
|
+
} else {
|
|
233
|
+
collapsedEdges.push({ fromNode: edge.fromNode, to: edge.to, facts: edge.fromFact !== undefined ? [edge.fromFact] : [] });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const edge of collapsedEdges) {
|
|
237
|
+
const producer = mustGet(wiringById, edge.fromNode);
|
|
238
|
+
const consumer = mustGet(wiringById, edge.to);
|
|
239
|
+
const flow: Omit<Flow, "id"> = { source: producer.exit, target: consumer.entry };
|
|
240
|
+
flows.push(edge.facts.length > 0 ? { ...flow, name: edge.facts.join(", ") } : flow);
|
|
241
|
+
}
|
|
242
|
+
// 5. Leaf(s) → End.
|
|
243
|
+
if (leaves.length === 1) {
|
|
244
|
+
flows.push({ source: mustGet(wiringById, leaves[0].id).exit, target: "End" });
|
|
245
|
+
} else if (leaves.length > 1) {
|
|
246
|
+
for (const leaf of leaves) {
|
|
247
|
+
flows.push({ source: mustGet(wiringById, leaf.id).exit, target: "gwj_end" });
|
|
248
|
+
}
|
|
249
|
+
flows.push({ source: "gwj_end", target: "End" });
|
|
250
|
+
}
|
|
251
|
+
const numberedFlows: Flow[] = flows.map((f, i) => ({ id: `f${i}`, ...f }));
|
|
252
|
+
|
|
253
|
+
const bpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway);
|
|
254
|
+
const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
|
|
255
|
+
const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
|
|
256
|
+
const humanNodes = buildHumanNodes(nodes);
|
|
257
|
+
const sideEffects = buildSideEffects(nodes);
|
|
258
|
+
|
|
259
|
+
return { ok: true, diagram, bpmn, resolved, humanNodes, sideEffects };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Push `value` into `list` (may be undefined for a dangling target, already reported by the
|
|
263
|
+
* validator) only when not already present — keeps adjacency de-duplicated. */
|
|
264
|
+
function pushUnique(list: string[] | undefined, value: string): void {
|
|
265
|
+
if (list && !list.includes(value)) list.push(value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Build the resolved/normalised graph — nodes (sorted) with their compiled element id, engine-native
|
|
269
|
+
* called element, typed emits, and sorted `dependsOn`; plus the resolved, sorted edges. */
|
|
270
|
+
function buildResolved(
|
|
271
|
+
graph: DeliveryGraph,
|
|
272
|
+
wirings: readonly NodeWiring[],
|
|
273
|
+
edges: readonly ResolvedDeliveryEdge[],
|
|
274
|
+
producersById: ReadonlyMap<string, string[]>,
|
|
275
|
+
): CompileDeliveryGraphResult["resolved"] {
|
|
276
|
+
const nodes: ResolvedDeliveryNode[] = wirings.map((w) => {
|
|
277
|
+
const base: ResolvedDeliveryNode = {
|
|
278
|
+
id: w.node.id,
|
|
279
|
+
kind: w.node.kind,
|
|
280
|
+
element: w.element,
|
|
281
|
+
emits: normaliseEmits(w.node),
|
|
282
|
+
dependsOn: [...(producersById.get(w.node.id) ?? [])],
|
|
283
|
+
};
|
|
284
|
+
return w.node.kind === "human" ? base : { ...base, calledElement: CALLED_ELEMENT[w.node.kind] };
|
|
285
|
+
});
|
|
286
|
+
const resolved: CompileDeliveryGraphResult["resolved"] = { nodes, edges: [...edges] };
|
|
287
|
+
return graph.name !== undefined ? { name: graph.name, ...resolved } : resolved;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Extract the human STOP-points (sorted by id) — where the graph pauses for a person/agent, with the
|
|
291
|
+
* instruction, optional attached form, and the typed facts the node will emit. */
|
|
292
|
+
function buildHumanNodes(nodes: readonly DeliveryNode[]): DeliveryHumanStop[] {
|
|
293
|
+
const stops: DeliveryHumanStop[] = [];
|
|
294
|
+
for (const node of nodes) {
|
|
295
|
+
if (node.kind !== "human") continue;
|
|
296
|
+
const stop: DeliveryHumanStop = { nodeId: node.id, emits: normaliseEmits(node) };
|
|
297
|
+
const withPrompt = node.human?.prompt !== undefined ? { ...stop, prompt: node.human.prompt } : stop;
|
|
298
|
+
stops.push(node.human?.formKey !== undefined ? { ...withPrompt, formKey: node.human.formKey } : withPrompt);
|
|
299
|
+
}
|
|
300
|
+
return stops;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Extract the SIDE EFFECTS (sorted by id) the compiled graph will perform — `agent` job runs and
|
|
304
|
+
* `connector` outbound actions. `wait` gates are read-only and `human` stops are surfaced separately,
|
|
305
|
+
* so neither is a side effect. */
|
|
306
|
+
function buildSideEffects(nodes: readonly DeliveryNode[]): DeliverySideEffect[] {
|
|
307
|
+
const effects: DeliverySideEffect[] = [];
|
|
308
|
+
for (const node of nodes) {
|
|
309
|
+
if (node.kind === "agent") {
|
|
310
|
+
effects.push({
|
|
311
|
+
nodeId: node.id,
|
|
312
|
+
kind: "agent",
|
|
313
|
+
description: `runs agent job \`${node.agent.jobType}\``,
|
|
314
|
+
});
|
|
315
|
+
} else if (node.kind === "connector") {
|
|
316
|
+
const effect: DeliverySideEffect = {
|
|
317
|
+
nodeId: node.id,
|
|
318
|
+
kind: "connector",
|
|
319
|
+
description: `invokes connector target \`${node.connector.target}\``,
|
|
320
|
+
};
|
|
321
|
+
effects.push(
|
|
322
|
+
node.connector.dedupeKey !== undefined ? { ...effect, dedupeKey: node.connector.dedupeKey } : effect,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return effects;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Render the compiled one-shot BPMN process definition (compile-to-native). Deterministic — element
|
|
330
|
+
* order is fixed (start, gateways, nodes sorted, end) and every id is positional. */
|
|
331
|
+
function renderBpmn(
|
|
332
|
+
graph: DeliveryGraph,
|
|
333
|
+
wirings: readonly NodeWiring[],
|
|
334
|
+
flows: readonly Flow[],
|
|
335
|
+
startForkGateway: string | undefined,
|
|
336
|
+
endJoinGateway: string | undefined,
|
|
337
|
+
): string {
|
|
338
|
+
// Precompute incoming/outgoing flow-id maps once (single pass over flows) so BPMN rendering stays
|
|
339
|
+
// linear in the number of flows instead of O(elements * flows) from repeated full-array filtering.
|
|
340
|
+
// Insertion order is preserved, matching the previous per-element filter order.
|
|
341
|
+
const incomingById = new Map<string, string[]>();
|
|
342
|
+
const outgoingById = new Map<string, string[]>();
|
|
343
|
+
const appendTo = (map: Map<string, string[]>, key: string, id: string): void => {
|
|
344
|
+
const list = map.get(key);
|
|
345
|
+
if (list) list.push(id);
|
|
346
|
+
else map.set(key, [id]);
|
|
347
|
+
};
|
|
348
|
+
for (const f of flows) {
|
|
349
|
+
appendTo(incomingById, f.target, f.id);
|
|
350
|
+
appendTo(outgoingById, f.source, f.id);
|
|
351
|
+
}
|
|
352
|
+
const incoming = (elementId: string): string[] => incomingById.get(elementId) ?? [];
|
|
353
|
+
const outgoing = (elementId: string): string[] => outgoingById.get(elementId) ?? [];
|
|
354
|
+
const refs = (tag: string, ids: readonly string[]): string =>
|
|
355
|
+
ids.map((id) => ` <bpmn:${tag}>${id}</bpmn:${tag}>`).join("\n");
|
|
356
|
+
|
|
357
|
+
const lines: string[] = [];
|
|
358
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
359
|
+
lines.push(
|
|
360
|
+
'<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ' +
|
|
361
|
+
'xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ' +
|
|
362
|
+
'id="Definitions_delivery_graph" targetNamespace="http://nanobpm.io/nano-workforce">',
|
|
363
|
+
);
|
|
364
|
+
const processName = graph.name ?? "Delivery graph";
|
|
365
|
+
lines.push(` <bpmn:process id="delivery-graph" name="${escapeXml(processName)}" isExecutable="true">`);
|
|
366
|
+
|
|
367
|
+
// Start event.
|
|
368
|
+
lines.push(' <bpmn:startEvent id="Start" name="Graph opened">');
|
|
369
|
+
lines.push(refs("outgoing", outgoing("Start")));
|
|
370
|
+
lines.push(" </bpmn:startEvent>");
|
|
371
|
+
|
|
372
|
+
// Start fork gateway (fan-out to multiple roots).
|
|
373
|
+
if (startForkGateway) {
|
|
374
|
+
lines.push(` <bpmn:parallelGateway id="${startForkGateway}" name="fan out to roots">`);
|
|
375
|
+
lines.push(refs("incoming", incoming(startForkGateway)));
|
|
376
|
+
lines.push(refs("outgoing", outgoing(startForkGateway)));
|
|
377
|
+
lines.push(" </bpmn:parallelGateway>");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Node elements (sorted), each preceded by its join gateway and followed by its fork gateway.
|
|
381
|
+
for (const w of wirings) {
|
|
382
|
+
if (w.joinGateway) {
|
|
383
|
+
lines.push(` <bpmn:parallelGateway id="${w.joinGateway}" name="join into ${escapeXml(w.node.id)}">`);
|
|
384
|
+
lines.push(refs("incoming", incoming(w.joinGateway)));
|
|
385
|
+
lines.push(refs("outgoing", outgoing(w.joinGateway)));
|
|
386
|
+
lines.push(" </bpmn:parallelGateway>");
|
|
387
|
+
}
|
|
388
|
+
lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element)));
|
|
389
|
+
if (w.forkGateway) {
|
|
390
|
+
lines.push(` <bpmn:parallelGateway id="${w.forkGateway}" name="fan out of ${escapeXml(w.node.id)}">`);
|
|
391
|
+
lines.push(refs("incoming", incoming(w.forkGateway)));
|
|
392
|
+
lines.push(refs("outgoing", outgoing(w.forkGateway)));
|
|
393
|
+
lines.push(" </bpmn:parallelGateway>");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// End join gateway (fan-in from multiple leaves) + end event.
|
|
398
|
+
if (endJoinGateway) {
|
|
399
|
+
lines.push(` <bpmn:parallelGateway id="${endJoinGateway}" name="join leaves">`);
|
|
400
|
+
lines.push(refs("incoming", incoming(endJoinGateway)));
|
|
401
|
+
lines.push(refs("outgoing", outgoing(endJoinGateway)));
|
|
402
|
+
lines.push(" </bpmn:parallelGateway>");
|
|
403
|
+
}
|
|
404
|
+
lines.push(' <bpmn:endEvent id="End" name="Graph complete">');
|
|
405
|
+
lines.push(refs("incoming", incoming("End")));
|
|
406
|
+
lines.push(" </bpmn:endEvent>");
|
|
407
|
+
|
|
408
|
+
// Sequence flows.
|
|
409
|
+
for (const f of flows) {
|
|
410
|
+
const nameAttr = f.name !== undefined ? ` name="${escapeXml(f.name)}"` : "";
|
|
411
|
+
lines.push(
|
|
412
|
+
` <bpmn:sequenceFlow id="${f.id}"${nameAttr} sourceRef="${f.source}" targetRef="${f.target}" />`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
lines.push(" </bpmn:process>");
|
|
417
|
+
lines.push("</bpmn:definitions>");
|
|
418
|
+
// Drop empty ref lines (nodes/events with no incoming or outgoing) so the output stays clean.
|
|
419
|
+
return `${lines.filter((l) => l.length > 0).join("\n")}\n`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Render one node's BPMN element — a `callActivity` delegating to its engine-native body for
|
|
423
|
+
* `agent`/`wait`/`connector` (Decision 2), or a native `userTask` for `human`. The `switch` is
|
|
424
|
+
* EXHAUSTIVE over the closed kind union (the compile-time trust bound): a non-allowlisted kind cannot
|
|
425
|
+
* be instantiated. */
|
|
426
|
+
function renderNodeElement(w: NodeWiring, incoming: readonly string[], outgoing: readonly string[]): string {
|
|
427
|
+
const node = w.node;
|
|
428
|
+
const name = escapeXml(`${node.kind}: ${node.id}`);
|
|
429
|
+
const flowRefs =
|
|
430
|
+
incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`).join("\n") +
|
|
431
|
+
(incoming.length > 0 && outgoing.length > 0 ? "\n" : "") +
|
|
432
|
+
outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`).join("\n");
|
|
433
|
+
const body = flowRefs.length > 0 ? `\n${flowRefs}\n ` : "";
|
|
434
|
+
|
|
435
|
+
switch (node.kind) {
|
|
436
|
+
case "agent":
|
|
437
|
+
case "wait":
|
|
438
|
+
case "connector": {
|
|
439
|
+
const called = CALLED_ELEMENT[node.kind];
|
|
440
|
+
return (
|
|
441
|
+
` <bpmn:callActivity id="${w.element}" name="${name}">\n` +
|
|
442
|
+
` <bpmn:extensionElements>\n` +
|
|
443
|
+
` <zeebe:calledElement processId="${called}" propagateAllChildVariables="false" />\n` +
|
|
444
|
+
` </bpmn:extensionElements>${body ? "" : "\n"}` +
|
|
445
|
+
(body ? body : "") +
|
|
446
|
+
`</bpmn:callActivity>`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
case "human":
|
|
450
|
+
return (
|
|
451
|
+
` <bpmn:userTask id="${w.element}" name="${name}">\n` +
|
|
452
|
+
` <bpmn:extensionElements>\n` +
|
|
453
|
+
` <zeebe:userTask />\n` +
|
|
454
|
+
` </bpmn:extensionElements>${body ? "" : "\n"}` +
|
|
455
|
+
(body ? body : "") +
|
|
456
|
+
`</bpmn:userTask>`
|
|
457
|
+
);
|
|
458
|
+
default:
|
|
459
|
+
return assertNever(node, "renderNodeElement");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Render a human-readable mermaid `flowchart` of the resolved graph — one node per box labelled
|
|
464
|
+
* `<kind>: <id>`, one arrow per edge (labelled with the referenced fact when qualified). Deterministic
|
|
465
|
+
* (nodes/edges already sorted). */
|
|
466
|
+
function renderMermaid(
|
|
467
|
+
graph: DeliveryGraph,
|
|
468
|
+
wirings: readonly NodeWiring[],
|
|
469
|
+
edges: readonly ResolvedDeliveryEdge[],
|
|
470
|
+
elementById: ReadonlyMap<string, string>,
|
|
471
|
+
): string {
|
|
472
|
+
const lines: string[] = ["flowchart TD"];
|
|
473
|
+
if (graph.name !== undefined) lines.push(` %% ${escapeMermaid(graph.name)}`);
|
|
474
|
+
for (const w of wirings) {
|
|
475
|
+
lines.push(` ${w.element}["${escapeMermaid(`${w.node.kind}: ${w.node.id}`)}"]`);
|
|
476
|
+
}
|
|
477
|
+
for (const edge of edges) {
|
|
478
|
+
const from = mustGet(elementById, edge.fromNode);
|
|
479
|
+
const to = mustGet(elementById, edge.to);
|
|
480
|
+
if (edge.fromFact !== undefined) {
|
|
481
|
+
lines.push(` ${from} -- "${escapeMermaid(edge.fromFact)}" --> ${to}`);
|
|
482
|
+
} else {
|
|
483
|
+
lines.push(` ${from} --> ${to}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return `${lines.join("\n")}\n`;
|
|
487
|
+
}
|
|
@@ -80,7 +80,7 @@ test("merge-protocol: only a `ui` land method is decision-required; the rest are
|
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
// --- task (plan-fanout w_gw "
|
|
83
|
+
// --- task (plan-fanout w_gw "clean terminal?" — the implement-stage escalation net, #360) ---
|
|
84
84
|
|
|
85
85
|
test("task: status=escalated with an answerable question is decision-required; blank is none", () => {
|
|
86
86
|
assertEquals(classifyEscalation({ kind: "task", question: "which approach?" }), "decision-required");
|
|
@@ -39,8 +39,10 @@ export type EscalationKind =
|
|
|
39
39
|
| "dead-end-base"
|
|
40
40
|
// `mergeProtocol` (app/mergeProtocol.ts) — the repo's declared land method.
|
|
41
41
|
| "merge-protocol"
|
|
42
|
-
// plan-fanout `w_gw` "
|
|
43
|
-
//
|
|
42
|
+
// plan-fanout `w_gw` "clean terminal?" gateway — the implement-stage escalation net (issue #360).
|
|
43
|
+
// Any non-clean-terminal slice outcome routes through the `record-wave-escalation` worker, which
|
|
44
|
+
// classifies with this kind: the agent's own answerable question passes through, and a no-machine-
|
|
45
|
+
// readable result (or a blank-question `escalated`) is synthesised into an answerable one.
|
|
44
46
|
| "task";
|
|
45
47
|
|
|
46
48
|
/** Everything the classifier may need from any raise site. Each field is consumed only by the
|