@nanobpm/nano-workforce 0.179.2 → 0.181.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/agentGuide.test.ts +87 -0
- package/app/agentGuide.ts +61 -0
- package/app/deliveryGraph.ts +1 -1
- package/app/deliveryGraphCompiler.test.ts +101 -2
- package/app/deliveryGraphCompiler.ts +164 -21
- package/app/deliveryGraphProposals.test.ts +60 -0
- package/app/deliveryGraphProposals.ts +61 -3
- package/app/deliveryGraphStage.ts +11 -1
- package/app/sequenceIssues.test.ts +131 -0
- package/app/sequenceIssues.ts +238 -29
- package/e2e/addressable-guide.e2e.ts +43 -0
- package/e2e/delivery-graph.e2e.ts +61 -0
- package/openapi.yaml +219 -21
- package/operations/compileDeliveryGraph.test.ts +4 -0
- package/operations/getAgentGuide.test.ts +13 -0
- package/operations/getAgentGuide.ts +40 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.181.0](https://github.com/nanobpm/nano-workforce/compare/v0.180.0...v0.181.0) (2026-09-04)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* delivery-graph MCP DX — interleaved sequence gates, paginated guide sections, stage-supersede visibility ([#741](https://github.com/nanobpm/nano-workforce/issues/741)) ([6f10e8a](https://github.com/nanobpm/nano-workforce/commit/6f10e8a25721645af4e5f932f2cbaa9ad6025b70)), closes [#740](https://github.com/nanobpm/nano-workforce/issues/740) [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#740](https://github.com/nanobpm/nano-workforce/issues/740)
|
|
6
|
+
|
|
7
|
+
## [0.180.0](https://github.com/nanobpm/nano-workforce/compare/v0.179.2...v0.180.0) (2026-09-04)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** gate agent-node completion on producer status + required emits ([#735](https://github.com/nanobpm/nano-workforce/issues/735)) ([a765e93](https://github.com/nanobpm/nano-workforce/commit/a765e937dc16f3b8f9dd1e1dedb489be697435e0)), closes [#731](https://github.com/nanobpm/nano-workforce/issues/731) [#731](https://github.com/nanobpm/nano-workforce/issues/731) [#731](https://github.com/nanobpm/nano-workforce/issues/731)
|
|
12
|
+
|
|
1
13
|
## [0.179.2](https://github.com/nanobpm/nano-workforce/compare/v0.179.1...v0.179.2) (2026-09-04)
|
|
2
14
|
|
|
3
15
|
### Code Refactoring
|
package/app/agentGuide.test.ts
CHANGED
|
@@ -10,9 +10,11 @@ import { test } from "node:test";
|
|
|
10
10
|
import { assert, assertEquals } from "#test-assert";
|
|
11
11
|
import {
|
|
12
12
|
GUIDE_SECTIONS,
|
|
13
|
+
GUIDE_SECTION_PAGE_DEFAULT,
|
|
13
14
|
guideToc,
|
|
14
15
|
renderAgentGuide,
|
|
15
16
|
renderGuideSection,
|
|
17
|
+
renderGuideSectionChunk,
|
|
16
18
|
splitGuideSections,
|
|
17
19
|
} from "./agentGuide.ts";
|
|
18
20
|
|
|
@@ -105,6 +107,91 @@ test("an unknown section id resolves to undefined (the op turns that into a 400)
|
|
|
105
107
|
assertEquals(renderGuideSection("does-not-exist", "https://x/app/api"), undefined);
|
|
106
108
|
});
|
|
107
109
|
|
|
110
|
+
// ── Paginated section retrieval (issue #740) ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
test("renderGuideSectionChunk: paging through a section reassembles it exactly, chunk by chunk", () => {
|
|
113
|
+
const base = "https://x/app/api";
|
|
114
|
+
const full = renderGuideSection("delivery-graphs", base)!;
|
|
115
|
+
const totalChars = Array.from(full).length;
|
|
116
|
+
const PAGE = 4000;
|
|
117
|
+
let start = 0;
|
|
118
|
+
let assembled = "";
|
|
119
|
+
let pages = 0;
|
|
120
|
+
let lastTotal = -1;
|
|
121
|
+
// Follow the nextStart cursor until it is null — the classic pagination loop.
|
|
122
|
+
for (;;) {
|
|
123
|
+
const chunk = renderGuideSectionChunk("delivery-graphs", base, start, PAGE)!;
|
|
124
|
+
assert(chunk, "a known section must page");
|
|
125
|
+
assert(Array.from(chunk.instructions).length <= PAGE, "a page must be bounded by the window");
|
|
126
|
+
assertEquals(chunk.start, start);
|
|
127
|
+
assertEquals(chunk.totalLength, totalChars);
|
|
128
|
+
if (lastTotal !== -1) assertEquals(chunk.totalLength, lastTotal);
|
|
129
|
+
lastTotal = chunk.totalLength;
|
|
130
|
+
assembled += chunk.instructions;
|
|
131
|
+
pages++;
|
|
132
|
+
if (chunk.nextStart === null) break;
|
|
133
|
+
start = chunk.nextStart;
|
|
134
|
+
assert(pages < 100, "pagination must terminate");
|
|
135
|
+
}
|
|
136
|
+
assert(pages > 1, "a large section must span multiple pages at this window");
|
|
137
|
+
assertEquals(assembled, full, "the reassembled pages must equal the whole section");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("renderGuideSectionChunk: a window past the end returns an empty last page with nextStart null", () => {
|
|
141
|
+
const base = "https://x/app/api";
|
|
142
|
+
const full = renderGuideSection("orient", base)!;
|
|
143
|
+
const total = Array.from(full).length;
|
|
144
|
+
const chunk = renderGuideSectionChunk("orient", base, total + 500, 100)!;
|
|
145
|
+
assertEquals(chunk.start, total);
|
|
146
|
+
assertEquals(chunk.length, 0);
|
|
147
|
+
assertEquals(chunk.instructions, "");
|
|
148
|
+
assertEquals(chunk.nextStart, null);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("renderGuideSectionChunk: the first page of a fitting section equals the whole section (nextStart null)", () => {
|
|
152
|
+
const base = "https://x/app/api";
|
|
153
|
+
const full = renderGuideSection("orient", base)!;
|
|
154
|
+
const chunk = renderGuideSectionChunk("orient", base, 0, GUIDE_SECTION_PAGE_DEFAULT)!;
|
|
155
|
+
// `orient` fits well under the default page, so one page carries it whole with no continuation.
|
|
156
|
+
assertEquals(chunk.instructions, full);
|
|
157
|
+
assertEquals(chunk.nextStart, null);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("renderGuideSectionChunk: a zero-length window on a non-empty section terminates (nextStart null, no loop)", () => {
|
|
161
|
+
const base = "https://x/app/api";
|
|
162
|
+
const full = renderGuideSection("delivery-graphs", base)!;
|
|
163
|
+
assert(Array.from(full).length > 0, "precondition: the section has content");
|
|
164
|
+
// A caller (or a direct invoker bypassing the op's `length >= 1` validation) that asks for a
|
|
165
|
+
// zero-length window gets an empty page — but `nextStart` MUST be null so a "page until nextStart
|
|
166
|
+
// is null" loop cannot spin forever on a cursor that never advances.
|
|
167
|
+
const chunk = renderGuideSectionChunk("delivery-graphs", base, 0, 0)!;
|
|
168
|
+
assertEquals(chunk.start, 0);
|
|
169
|
+
assertEquals(chunk.length, 0);
|
|
170
|
+
assertEquals(chunk.instructions, "");
|
|
171
|
+
assertEquals(chunk.nextStart, null);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("renderGuideSectionChunk: an unknown section id is undefined (the op turns that into a 400)", () => {
|
|
175
|
+
assertEquals(renderGuideSectionChunk("does-not-exist", "https://x/app/api", 0, 100), undefined);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("renderGuideSectionChunk: chunk boundaries never split a multi-byte character", () => {
|
|
179
|
+
const base = "https://x/app/api";
|
|
180
|
+
const full = renderGuideSection("delivery-graphs", base)!;
|
|
181
|
+
// Page at every 1-char window across a stretch that contains the guide's arrows/emoji; each
|
|
182
|
+
// reassembled result must round-trip losslessly (no U+FFFD replacement from a split code point).
|
|
183
|
+
let assembled = "";
|
|
184
|
+
let start = 0;
|
|
185
|
+
for (let i = 0; i < 200 && start < Array.from(full).length; i++) {
|
|
186
|
+
const chunk = renderGuideSectionChunk("delivery-graphs", base, start, 7)!;
|
|
187
|
+
assembled += chunk.instructions;
|
|
188
|
+
if (chunk.nextStart === null) break;
|
|
189
|
+
start = chunk.nextStart;
|
|
190
|
+
}
|
|
191
|
+
assert(!assembled.includes("\uFFFD"), "no replacement characters — code points were never split");
|
|
192
|
+
assertEquals(assembled, Array.from(full).slice(0, Array.from(assembled).length).join(""));
|
|
193
|
+
});
|
|
194
|
+
|
|
108
195
|
test("no content regression: each section body is a verbatim slice of the raw guide", () => {
|
|
109
196
|
// Rendering keys examples to an instance; the UN-substituted section bodies must be exact
|
|
110
197
|
// substrings of the authored doc, so the addressable surface never rewrites guide content.
|
package/app/agentGuide.ts
CHANGED
|
@@ -174,3 +174,64 @@ export function renderGuideSection(id: string, apiBase: string): string | undefi
|
|
|
174
174
|
const base = apiBase.replace(/\/+$/, "");
|
|
175
175
|
return section.body.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
// Paginated section retrieval (issue #740).
|
|
180
|
+
//
|
|
181
|
+
// The addressable guide exists to avoid the ~43KB monolith, but a single section can ITSELF overflow
|
|
182
|
+
// a typical MCP tool-result limit — `delivery-graphs` alone renders to ~25KB, the one section an
|
|
183
|
+
// author most needs. So a section is additionally retrievable in BOUNDED CHUNKS via a `start`/`length`
|
|
184
|
+
// window with a `nextStart` continuation cursor. Offsets are UNICODE CHARACTER (code-point) offsets —
|
|
185
|
+
// NOT raw byte offsets — so a chunk boundary never splits a multi-byte character (the arrows/emoji in
|
|
186
|
+
// the guide) into mojibake. A plain `renderGuideSection` (no window) is untouched, so existing
|
|
187
|
+
// `section=<id>` calls that already fit stay byte-for-byte identical.
|
|
188
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/** The default window size (characters) when a caller engages pagination without an explicit
|
|
191
|
+
* `length` — comfortably under a typical MCP tool-result limit so a single page never overflows. */
|
|
192
|
+
export const GUIDE_SECTION_PAGE_DEFAULT = 12_000;
|
|
193
|
+
|
|
194
|
+
/** One page of a section's markdown: the `instructions` slice plus the cursor state so a caller can
|
|
195
|
+
* page through with `nextStart` until it is `null`. Offsets/lengths are CHARACTER counts. */
|
|
196
|
+
export interface GuideSectionChunk {
|
|
197
|
+
readonly instructions: string;
|
|
198
|
+
/** The (clamped) character offset this page starts at. */
|
|
199
|
+
readonly start: number;
|
|
200
|
+
/** The number of characters actually returned in this page. */
|
|
201
|
+
readonly length: number;
|
|
202
|
+
/** The total number of characters in the fully-rendered section. */
|
|
203
|
+
readonly totalLength: number;
|
|
204
|
+
/** The character offset to pass as `start` for the next page, or `null` when this is the last page. */
|
|
205
|
+
readonly nextStart: number | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Render a bounded WINDOW of a single section's markdown for a given app control-API base, with
|
|
209
|
+
* `__BASE__`/`__ENGINE__` substituted exactly as {@link renderGuideSection} does. `start` and
|
|
210
|
+
* `length` are CHARACTER offsets (clamped to the section bounds; a non-positive `length` yields an
|
|
211
|
+
* empty page). Returns `undefined` for an unknown id (the caller turns that into a 400). */
|
|
212
|
+
export function renderGuideSectionChunk(
|
|
213
|
+
id: string,
|
|
214
|
+
apiBase: string,
|
|
215
|
+
start: number,
|
|
216
|
+
length: number,
|
|
217
|
+
): GuideSectionChunk | undefined {
|
|
218
|
+
const full = renderGuideSection(id, apiBase);
|
|
219
|
+
if (full === undefined) return undefined;
|
|
220
|
+
const chars = Array.from(full);
|
|
221
|
+
const total = chars.length;
|
|
222
|
+
const from = Math.min(Math.max(0, Math.trunc(start)), total);
|
|
223
|
+
const take = Math.max(0, Math.trunc(length));
|
|
224
|
+
const slice = chars.slice(from, from + take);
|
|
225
|
+
const end = from + slice.length;
|
|
226
|
+
return {
|
|
227
|
+
instructions: slice.join(""),
|
|
228
|
+
start: from,
|
|
229
|
+
length: slice.length,
|
|
230
|
+
totalLength: total,
|
|
231
|
+
// Only advance when this page actually consumed characters. A zero-length window (`length <= 0`)
|
|
232
|
+
// returns an empty page that made NO progress, so it must terminate the cursor (`null`) rather
|
|
233
|
+
// than hand back a `nextStart` equal to `start` — a non-advancing cursor would loop a caller
|
|
234
|
+
// that pages until `nextStart` is null forever.
|
|
235
|
+
nextStart: slice.length > 0 && end < total ? end : null,
|
|
236
|
+
};
|
|
237
|
+
}
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -164,7 +164,7 @@ const GRAPH_NAME_MAX_LENGTH = 255;
|
|
|
164
164
|
* like the library import/save doors' `graphJson`, where the schema never touches the parsed value)
|
|
165
165
|
* would otherwise let an oversized-but-compilable graph reach the layout/compiler and be persisted —
|
|
166
166
|
* both violating the declared contract and exposing the import path to avoidable CPU/memory growth. */
|
|
167
|
-
const GRAPH_MAX_NODES = 256;
|
|
167
|
+
export const GRAPH_MAX_NODES = 256;
|
|
168
168
|
const GRAPH_MAX_EDGES = 1024;
|
|
169
169
|
const NODE_MAX_EMITS = 32;
|
|
170
170
|
|
|
@@ -30,12 +30,13 @@ async function compileFail(graph: unknown) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** The sub-process element id of the PLANNED human user task in a compiled graph — i.e. the
|
|
33
|
-
* delivery-human-task element that is NOT a bounded node's
|
|
33
|
+
* delivery-human-task element that is NOT a bounded node's escalation twin (`__esc` timeout or
|
|
34
|
+
* `__contract` producer-gate, issue #731). Returns "" if none. */
|
|
34
35
|
function humanTaskSubEl(bpmn: string): string {
|
|
35
36
|
const parts = bpmn.split('<bpmn:userTask id="delivery-human-task__');
|
|
36
37
|
for (let k = 1; k < parts.length; k++) {
|
|
37
38
|
const id = parts[k].slice(0, parts[k].indexOf('"'));
|
|
38
|
-
if (!id.endsWith("__esc")) return id;
|
|
39
|
+
if (!id.endsWith("__esc") && !id.endsWith("__contract")) return id;
|
|
39
40
|
}
|
|
40
41
|
return "";
|
|
41
42
|
}
|
|
@@ -293,6 +294,15 @@ function escBlockForNode(bpmn: string, nodeId: string): string {
|
|
|
293
294
|
return bpmn.slice(start, bpmn.indexOf("</bpmn:userTask>", start));
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
/** Slice a compiled BPMN to a node's escalation user task body by twin suffix (`esc` timeout or
|
|
298
|
+
* `contract` producer-gate, issue #731). */
|
|
299
|
+
function escBlockForNodeSuffix(bpmn: string, nodeId: string, suffix: "esc" | "contract"): string {
|
|
300
|
+
const esc = `delivery-human-task__${elementForNode(bpmn, nodeId)}__${suffix}`;
|
|
301
|
+
const start = bpmn.indexOf(`<bpmn:userTask id="${esc}"`);
|
|
302
|
+
assert(start !== -1, `escalation task ${esc} for node ${nodeId} exists`);
|
|
303
|
+
return bpmn.slice(start, bpmn.indexOf("</bpmn:userTask>", start));
|
|
304
|
+
}
|
|
305
|
+
|
|
296
306
|
test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last detail, target/match, and observed releases so it is self-diagnosing", async () => {
|
|
297
307
|
const r = await compileOk(CAP_GATE);
|
|
298
308
|
const esc = escBlockForNode(r.bpmn, "n2");
|
|
@@ -775,3 +785,92 @@ test("a wait node's onTimeout: fail is rejected at compile with a path-qualified
|
|
|
775
785
|
assert(hit, `expected a path-qualified onTimeout error, got ${JSON.stringify(errors)}`);
|
|
776
786
|
assert(hit?.message.includes("#978"), `the error names the blocking engine issue, got ${hit?.message}`);
|
|
777
787
|
});
|
|
788
|
+
|
|
789
|
+
// Issue #731 — the producer-contract gate. An `agent` node's job completing is NOT the node
|
|
790
|
+
// succeeding: a producer that returns a non-terminal `status` (the instance-10746 `in_progress`) or
|
|
791
|
+
// omits a declared emit consumed downstream as a required data dependency must escalate AT the
|
|
792
|
+
// producer, not thread a null/incomplete result into a consumer two nodes downstream. The routing-only
|
|
793
|
+
// emit (referenced only by an edge `when` guard) stays optional — omit ⇒ default branch.
|
|
794
|
+
|
|
795
|
+
// The canonical `agent → connector[converge-merge]` shape: `open` opens the PR and emits `pr`, which
|
|
796
|
+
// `land` binds as its connector `payload.pr` (a required DATA dependency, threaded on the fact edge).
|
|
797
|
+
const PRODUCER_GATE = {
|
|
798
|
+
name: "producer gate",
|
|
799
|
+
nodes: [
|
|
800
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "pr", type: "pr" }] },
|
|
801
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" }, dedupeKey: "land-1" } },
|
|
802
|
+
],
|
|
803
|
+
edges: [{ from: "open.pr", to: "land" }],
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
test("#731 producer status gate: an agent node inserts a post-completion contract gate that escalates a non-terminal status AT the producer", async () => {
|
|
807
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
808
|
+
const el = elementForNode(r.bpmn, "open");
|
|
809
|
+
// The agent body is no longer `task → end`: the task feeds an exclusive `_gate` whose default routes
|
|
810
|
+
// a broken producer to a SECOND (contract) escalation task distinct from the `__esc` timeout twin.
|
|
811
|
+
assert(
|
|
812
|
+
r.bpmn.includes(`<bpmn:exclusiveGateway id="${el}_gate" name="producer contract met?" default="${el}_g1">`),
|
|
813
|
+
"the agent task feeds a producer-contract exclusive gate",
|
|
814
|
+
);
|
|
815
|
+
assert(r.bpmn.includes(`<bpmn:sequenceFlow id="${el}_i1" sourceRef="${el}_task" targetRef="${el}_gate" />`), "the task flows into the gate, not straight to end");
|
|
816
|
+
assert(r.bpmn.includes(`<bpmn:userTask id="delivery-human-task__${el}__contract"`), "a producer-contract escalation task exists, distinct from the __esc timeout twin");
|
|
817
|
+
assert(
|
|
818
|
+
r.bpmn.includes(`<bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="delivery-human-task__${el}__contract" />`),
|
|
819
|
+
"the gate's default (contract-broken) flow parks the producer on its contract escalation",
|
|
820
|
+
);
|
|
821
|
+
// The success flow proceeds only on a terminal-success status (or an absent/null status); an
|
|
822
|
+
// `in_progress`/`blocked`/`failed` self-report falls through to the default → escalation.
|
|
823
|
+
const g0 = r.bpmn.match(new RegExp(`<bpmn:sequenceFlow id="${el}_g0"[^>]*>(.*?)</bpmn:sequenceFlow>`, "s"));
|
|
824
|
+
assert(g0, "the contract-met success flow exists");
|
|
825
|
+
assert(g0![1].includes('list contains(["done", "opened", "skipped"], status)'), "the success flow gates on the terminal-success status allowlist");
|
|
826
|
+
assert(g0![1].includes("not(is defined(status)) or status = null"), "an absent/null status is not itself the failure mode — it still proceeds");
|
|
827
|
+
// The contract escalation's read-only context names the node and its reported status (#731).
|
|
828
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
829
|
+
assert(esc.includes("did not satisfy its producer contract"), "the contract escalation explains WHY it parked");
|
|
830
|
+
assert(esc.includes("Reported status="), "the context surfaces the actual reported status");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("#731 required-emit gate: a producer's declared emit consumed as a required data dependency adds a non-null gate clause and a resumable escalation NAMING the fact", async () => {
|
|
834
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
835
|
+
const el = elementForNode(r.bpmn, "open");
|
|
836
|
+
const g0 = r.bpmn.match(new RegExp(`<bpmn:sequenceFlow id="${el}_g0"[^>]*>(.*?)</bpmn:sequenceFlow>`, "s"));
|
|
837
|
+
assert(g0, "the contract-met success flow exists");
|
|
838
|
+
// `pr` is threaded to `land`'s connector payload as a required data dependency — so the gate proceeds
|
|
839
|
+
// only when it is actually populated non-null (a null `pr`, as in instance 10746, escalates here).
|
|
840
|
+
assert(g0![1].includes("(is defined(pr) and pr != null)"), "a required-emit non-null clause gates the success flow on the populated fact");
|
|
841
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
842
|
+
assert(esc.includes("Required emit 'pr'"), "the escalation NAMES the required fact that was not emitted");
|
|
843
|
+
// Resumable (#514 Defect-B mirror): a human/agent supplies the missing fact, mapped onto the agent
|
|
844
|
+
// emit source var (fact name), so the subProcess output ioMapping republishes `<el>_pr` non-null.
|
|
845
|
+
assert(esc.includes('="typed"') && esc.includes('target="emitMode"'), "the contract escalation PRESENTS its value field (resumable)");
|
|
846
|
+
assert(/source="=if \(is defined\(value\)\) then value else null" target="pr"/.test(esc), "the operator's value maps onto the required emit's source var");
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("#731 routing-only emits stay optional: a fact referenced ONLY by an edge `when` guard is NOT gated as a required emit (omit ⇒ default branch)", async () => {
|
|
850
|
+
// `classify` emits `result` used ONLY for guarded routing (`when`/`equals` + a default) — never
|
|
851
|
+
// threaded as a fact-qualified `from` data dependency. The producer gate must NOT require it non-null.
|
|
852
|
+
const graph = {
|
|
853
|
+
name: "routing only",
|
|
854
|
+
nodes: [
|
|
855
|
+
{ id: "classify", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "result", type: "string" }] },
|
|
856
|
+
{ id: "migrate", kind: "connector", connector: { target: "npm:install", dedupeKey: "m-1" } },
|
|
857
|
+
{ id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "r-1" } },
|
|
858
|
+
],
|
|
859
|
+
edges: [
|
|
860
|
+
{ from: "classify", to: "migrate", when: "classify.result", equals: "breaking" },
|
|
861
|
+
{ from: "classify", to: "release", default: true },
|
|
862
|
+
],
|
|
863
|
+
};
|
|
864
|
+
const r = await compileOk(graph);
|
|
865
|
+
const el = elementForNode(r.bpmn, "classify");
|
|
866
|
+
const g0 = r.bpmn.match(new RegExp(`<bpmn:sequenceFlow id="${el}_g0"[^>]*>(.*?)</bpmn:sequenceFlow>`, "s"));
|
|
867
|
+
assert(g0, "the contract-met success flow exists");
|
|
868
|
+
// The status gate is still present, but there is NO `result` non-null clause — routing stays optional.
|
|
869
|
+
assert(g0![1].includes("list contains"), "the status gate is still present for the agent node");
|
|
870
|
+
assert(!g0![1].includes("result"), `a routing-only emit is NOT gated as a required data dependency, got: ${g0![1]}`);
|
|
871
|
+
// The contract escalation for a status-only gate is inert (no emit resume field).
|
|
872
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "classify", "contract");
|
|
873
|
+
assert(esc.includes('="none"') && esc.includes('target="emitMode"'), "a status-only contract escalation keeps its emit field hidden");
|
|
874
|
+
// The guarded split that routes `result` downstream is untouched (default branch preserved).
|
|
875
|
+
assert(r.bpmn.includes('=classify_result = "breaking"') || r.bpmn.includes(`${el}_result = "breaking"`), "the routing guard on the emitted fact is preserved");
|
|
876
|
+
});
|
|
@@ -90,6 +90,26 @@ function escalationTaskElement(element: string): string {
|
|
|
90
90
|
return `${DELIVERY_HUMAN_ELEMENT}__${element}__esc`;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** The BPMN element id an `agent` node's PRODUCER-CONTRACT escalation user task carries (issue #731) —
|
|
94
|
+
* distinct from the `__esc` timeout twin so a node can carry both a bounded-timeout escalation AND a
|
|
95
|
+
* post-completion contract-gate escalation without an id collision. Same human-completable convention
|
|
96
|
+
* (`delivery-human-task__…` → recognised by `isDeliveryHumanElement`, routed onto the Tasks inbox), so
|
|
97
|
+
* a producer that finishes without doing its job escalates AT that node and is answerable by a human
|
|
98
|
+
* OR an agent. */
|
|
99
|
+
function contractEscalationTaskElement(element: string): string {
|
|
100
|
+
return `${DELIVERY_HUMAN_ELEMENT}__${element}__contract`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The self-reported completion statuses an `agent` node's job may return that count as a TERMINAL
|
|
104
|
+
* SUCCESS and are allowed to route their result onward (issue #731). Everything else — the pathological
|
|
105
|
+
* `in_progress` an agent that delegated/returned-before-finishing reports (instance 10746), a `blocked`/
|
|
106
|
+
* `failed`/`escalated` give-up, or any unrecognised free-formed status — fails the producer status gate
|
|
107
|
+
* and escalates AT the node instead of threading an incomplete result into a downstream consumer. An
|
|
108
|
+
* ABSENT/null status passes the gate (a status-less completion — an older fleet worker or a bare test
|
|
109
|
+
* stub — is not itself the failure mode; the required-emit gate still catches a missing data fact).
|
|
110
|
+
* Sorted for the compiler's byte-identical-output determinism. */
|
|
111
|
+
const AGENT_TERMINAL_SUCCESS_STATUSES: readonly string[] = ["done", "opened", "skipped"];
|
|
112
|
+
|
|
93
113
|
/** A never-reached exhaustiveness guard: `compileNode`'s `switch` covers every allowlisted kind, so
|
|
94
114
|
* the closed union narrows to `never` here. If a future kind is added to the vocabulary without a
|
|
95
115
|
* compiler arm, `tsc` flags this call — the compile-time half of the trust bound. */
|
|
@@ -532,7 +552,24 @@ export async function compileDeliveryGraphSemantic(
|
|
|
532
552
|
list.sort((a, b) => byCodeUnit(a.producerElement, b.producerElement) || byCodeUnit(a.fact, b.fact));
|
|
533
553
|
}
|
|
534
554
|
|
|
535
|
-
|
|
555
|
+
// Producer-side required-emit gate (issue #731): the set of a producer's declared emit names that are
|
|
556
|
+
// consumed as a REQUIRED DATA DEPENDENCY downstream — i.e. threaded on a FACT-QUALIFIED edge
|
|
557
|
+
// (`from: "<node>.<fact>"`) into a consumer's connector `payload`/probe `target`. This is the SAME
|
|
558
|
+
// `<producerElement>_<fact>` wiring `boundInputsByElement` derives, keyed by the PRODUCER element so a
|
|
559
|
+
// node can gate its own completion on populating every fact a sibling depends on. A ROUTING emit
|
|
560
|
+
// (referenced only by an edge `when` guard, never as a fact-qualified `from`) is deliberately absent
|
|
561
|
+
// here — those stay optional (omit ⇒ default branch). Grouped by producer element; only set
|
|
562
|
+
// membership is ever queried downstream, so the sets carry no ordering guarantee.
|
|
563
|
+
const requiredEmitsByElement = new Map<string, Set<string>>();
|
|
564
|
+
for (const edge of resolvedEdges) {
|
|
565
|
+
if (edge.fromFact === undefined) continue;
|
|
566
|
+
const producerEl = mustGet(elementById, edge.fromNode);
|
|
567
|
+
const set = requiredEmitsByElement.get(producerEl) ?? new Set<string>();
|
|
568
|
+
set.add(edge.fromFact);
|
|
569
|
+
requiredEmitsByElement.set(producerEl, set);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const semanticBpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement, requiredEmitsByElement);
|
|
536
573
|
const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
|
|
537
574
|
const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
|
|
538
575
|
const humanNodes = buildHumanNodes(nodes);
|
|
@@ -698,6 +735,11 @@ function buildSideEffects(nodes: readonly DeliveryNode[]): DeliverySideEffect[]
|
|
|
698
735
|
return effects;
|
|
699
736
|
}
|
|
700
737
|
|
|
738
|
+
/** Shared empty required-emits set for nodes with no required emits — reused instead of allocating a
|
|
739
|
+
* fresh `new Set()` per such node while rendering. Safe because `requiredEmits` is only ever read
|
|
740
|
+
* (`ReadonlySet`). */
|
|
741
|
+
const EMPTY_REQUIRED_EMITS: ReadonlySet<string> = new Set<string>();
|
|
742
|
+
|
|
701
743
|
/** Render the compiled one-shot BPMN process definition (compile-to-native). Deterministic — element
|
|
702
744
|
* order is fixed (start, gateways, nodes sorted, end) and every id is positional. */
|
|
703
745
|
function renderBpmn(
|
|
@@ -707,6 +749,7 @@ function renderBpmn(
|
|
|
707
749
|
startForkGateway: string | undefined,
|
|
708
750
|
endJoinGateway: string | undefined,
|
|
709
751
|
boundInputsByElement: ReadonlyMap<string, BoundInput[]>,
|
|
752
|
+
requiredEmitsByElement: ReadonlyMap<string, ReadonlySet<string>>,
|
|
710
753
|
): string {
|
|
711
754
|
// Precompute incoming/outgoing flow-id maps once (single pass over flows) so BPMN rendering stays
|
|
712
755
|
// linear in the number of flows instead of O(elements * flows) from repeated full-array filtering.
|
|
@@ -779,7 +822,15 @@ function renderBpmn(
|
|
|
779
822
|
if (w.joinGateway) {
|
|
780
823
|
lines.push(...gateway(w.joinGateway, w.joinExclusive, `join into ${w.node.id}`));
|
|
781
824
|
}
|
|
782
|
-
lines.push(
|
|
825
|
+
lines.push(
|
|
826
|
+
renderNodeElement(
|
|
827
|
+
w,
|
|
828
|
+
incoming(w.element),
|
|
829
|
+
outgoing(w.element),
|
|
830
|
+
boundInputsByElement.get(w.element) ?? [],
|
|
831
|
+
requiredEmitsByElement.get(w.element) ?? EMPTY_REQUIRED_EMITS,
|
|
832
|
+
),
|
|
833
|
+
);
|
|
783
834
|
if (w.forkGateway) {
|
|
784
835
|
lines.push(...gateway(w.forkGateway, w.forkExclusive, `fan out of ${w.node.id}`));
|
|
785
836
|
}
|
|
@@ -835,6 +886,7 @@ function renderNodeElement(
|
|
|
835
886
|
incoming: readonly string[],
|
|
836
887
|
outgoing: readonly string[],
|
|
837
888
|
boundInputs: readonly BoundInput[],
|
|
889
|
+
requiredEmits: ReadonlySet<string>,
|
|
838
890
|
): string {
|
|
839
891
|
const el = w.element;
|
|
840
892
|
const name = escapeXml(`${w.node.kind}: ${w.node.id}`);
|
|
@@ -843,7 +895,7 @@ function renderNodeElement(
|
|
|
843
895
|
...outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`),
|
|
844
896
|
];
|
|
845
897
|
const io = ioMappingLines(w, boundInputs);
|
|
846
|
-
const inner = innerBodyLines(w);
|
|
898
|
+
const inner = innerBodyLines(w, requiredEmits);
|
|
847
899
|
const lines = [
|
|
848
900
|
` <bpmn:subProcess id="${el}" name="${name}">`,
|
|
849
901
|
...flowRefs,
|
|
@@ -965,12 +1017,18 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
965
1017
|
* the S3 scheduled user-task + generic form + SLA. Each is a single-entry / single-exit subgraph with
|
|
966
1018
|
* a bounded timeout that escalates onto a human-completable user task (or, for `human`, records an
|
|
967
1019
|
* escalated outcome). */
|
|
968
|
-
function innerBodyLines(w: NodeWiring): string[] {
|
|
1020
|
+
function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): string[] {
|
|
969
1021
|
const el = w.element;
|
|
970
1022
|
const node = w.node;
|
|
971
1023
|
switch (node.kind) {
|
|
972
|
-
case "agent":
|
|
973
|
-
|
|
1024
|
+
case "agent": {
|
|
1025
|
+
// Issue #731: an `agent` node gates its own completion on a producer contract — a terminal-success
|
|
1026
|
+
// self-reported `status` AND a non-null value for every declared emit a downstream consumer binds
|
|
1027
|
+
// as a required data dependency. A broken producer (returns `in_progress`, or omits a required
|
|
1028
|
+
// emit) escalates AT this node instead of threading an incomplete result onward.
|
|
1029
|
+
const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
|
|
1030
|
+
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate);
|
|
1031
|
+
}
|
|
974
1032
|
case "connector":
|
|
975
1033
|
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
|
|
976
1034
|
case "wait":
|
|
@@ -986,12 +1044,57 @@ function innerBodyLines(w: NodeWiring): string[] {
|
|
|
986
1044
|
* escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
|
|
987
1045
|
* `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
|
|
988
1046
|
* names the stalled work (job type / connector target) for the escalation task's context line (#499). */
|
|
1047
|
+
/** The FEEL boolean an `agent` node's producer-contract gate (issue #731) evaluates on its `_gate`
|
|
1048
|
+
* exclusive split's SUCCESS flow: the completion proceeds onward only when the self-reported `status`
|
|
1049
|
+
* is a terminal success (or absent/null) AND every required-data-dependency emit is populated non-null.
|
|
1050
|
+
* Reads the job's returned variables from the subProcess scope (the emit source var for an agent fact
|
|
1051
|
+
* is the fact's own name — see {@link factSourceVar}). When it is false the split's DEFAULT flow routes
|
|
1052
|
+
* to the contract-escalation task instead. */
|
|
1053
|
+
function agentContractProceedCondition(requiredEmits: readonly DeliveryFact[]): string {
|
|
1054
|
+
const statusList = `[${AGENT_TERMINAL_SUCCESS_STATUSES.map((s) => feelStr(s)).join(", ")}]`;
|
|
1055
|
+
const statusOk = `(not(is defined(status)) or status = null or list contains(${statusList}, status))`;
|
|
1056
|
+
const emitClauses = requiredEmits.map((f) => `(is defined(${f.name}) and ${f.name} != null)`);
|
|
1057
|
+
return `=${[statusOk, ...emitClauses].join(" and ")}`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** The read-only context line seeded onto an `agent` node's producer-contract escalation (issue #731),
|
|
1061
|
+
* so the human/agent unsticking it sees WHY it parked — the node, its job type, the actual reported
|
|
1062
|
+
* status, and, per required emit, whether it arrived. Turns the instance-10746 failure (a silent null
|
|
1063
|
+
* thread + two mis-attributed CONSUMER incidents) into one correctly-attributed PRODUCER escalation. */
|
|
1064
|
+
function agentContractContextFeel(nodeId: string, descriptor: string, requiredEmits: readonly DeliveryFact[]): string {
|
|
1065
|
+
const statuses = AGENT_TERMINAL_SUCCESS_STATUSES.join("/");
|
|
1066
|
+
const head = feelStr(
|
|
1067
|
+
`Node ${nodeId} (${descriptor}) completed but did not satisfy its producer contract — a producer must ` +
|
|
1068
|
+
`self-report a terminal-success status (${statuses}) and populate every emit a downstream node requires ` +
|
|
1069
|
+
"before its result routes onward. Reported status=",
|
|
1070
|
+
);
|
|
1071
|
+
let feel = `=${head} + (if (is defined(status) and status != null) then string(status) else "(none)") + "."`;
|
|
1072
|
+
for (const f of requiredEmits) {
|
|
1073
|
+
const present = `(is defined(${f.name}) and ${f.name} != null)`;
|
|
1074
|
+
feel += ` + " Required emit '${f.name}': " + (if ${present} then "present" else "MISSING (null)") + "."`;
|
|
1075
|
+
}
|
|
1076
|
+
return feel;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
|
|
1080
|
+
* escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
|
|
1081
|
+
* `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
|
|
1082
|
+
* names the stalled work (job type / connector target) for the escalation task's context line (#499).
|
|
1083
|
+
*
|
|
1084
|
+
* `contractGate` (agent only, issue #731) inserts a PRODUCER post-condition between the task and the
|
|
1085
|
+
* end: an exclusive split whose SUCCESS flow ({@link agentContractProceedCondition}) proceeds only on a
|
|
1086
|
+
* terminal-success `status` AND non-null required emits, and whose DEFAULT flow parks a broken producer
|
|
1087
|
+
* on a SECOND (contract) escalation task — distinct from the `__esc` timeout twin. That escalation is
|
|
1088
|
+
* RESUMABLE with the node's required emits (a human/agent supplies the missing fact, which the
|
|
1089
|
+
* subProcess output mapping then publishes as `<el>_<fact>`), mirroring the #514 Defect-B wait resume.
|
|
1090
|
+
* Omitted for a `connector` (no self-reported status contract), whose body stays `task → end`. */
|
|
989
1091
|
function serviceBodyLines(
|
|
990
1092
|
el: string,
|
|
991
1093
|
nodeId: string,
|
|
992
1094
|
taskDefAttr: string,
|
|
993
1095
|
taskProps: readonly string[],
|
|
994
1096
|
descriptor: string,
|
|
1097
|
+
contractGate?: { requiredEmits: readonly DeliveryFact[] },
|
|
995
1098
|
): string[] {
|
|
996
1099
|
const esc = escalationTaskElement(el);
|
|
997
1100
|
const taskExt =
|
|
@@ -1009,7 +1112,19 @@ function serviceBodyLines(
|
|
|
1009
1112
|
` <zeebe:taskDefinition ${taskDefAttr} />`,
|
|
1010
1113
|
" </bpmn:extensionElements>",
|
|
1011
1114
|
];
|
|
1012
|
-
|
|
1115
|
+
const timeoutEscalation = escalationTaskLines(
|
|
1116
|
+
esc,
|
|
1117
|
+
nodeId,
|
|
1118
|
+
[`${el}_i2`],
|
|
1119
|
+
`${el}_i3`,
|
|
1120
|
+
escalationContextFeel(
|
|
1121
|
+
nodeId,
|
|
1122
|
+
descriptor,
|
|
1123
|
+
"nodeTimeout",
|
|
1124
|
+
"; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
|
|
1125
|
+
),
|
|
1126
|
+
);
|
|
1127
|
+
const head = [
|
|
1013
1128
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
1014
1129
|
` <bpmn:serviceTask id="${el}_task" name="${escapeXml(nodeId)}">`,
|
|
1015
1130
|
...taskExt,
|
|
@@ -1020,21 +1135,49 @@ function serviceBodyLines(
|
|
|
1020
1135
|
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
1021
1136
|
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=nodeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
1022
1137
|
" </bpmn:boundaryEvent>",
|
|
1023
|
-
...
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
),
|
|
1034
|
-
|
|
1035
|
-
|
|
1138
|
+
...timeoutEscalation,
|
|
1139
|
+
];
|
|
1140
|
+
|
|
1141
|
+
if (contractGate === undefined) {
|
|
1142
|
+
return [
|
|
1143
|
+
...head,
|
|
1144
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
|
|
1145
|
+
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
1146
|
+
flow(`${el}_i1`, `${el}_task`, `${el}_end`),
|
|
1147
|
+
flow(`${el}_i2`, `${el}_be`, esc),
|
|
1148
|
+
flow(`${el}_i3`, esc, `${el}_end`),
|
|
1149
|
+
];
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end.
|
|
1153
|
+
const contractEsc = contractEscalationTaskElement(el);
|
|
1154
|
+
const emits = contractGate.requiredEmits;
|
|
1155
|
+
const proceedCondition = agentContractProceedCondition(emits);
|
|
1156
|
+
const contractEscalation = escalationTaskLines(
|
|
1157
|
+
contractEsc,
|
|
1158
|
+
nodeId,
|
|
1159
|
+
[`${el}_g1`],
|
|
1160
|
+
`${el}_g2`,
|
|
1161
|
+
agentContractContextFeel(nodeId, descriptor, emits),
|
|
1162
|
+
// Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
|
|
1163
|
+
// the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
|
|
1164
|
+
// so the downstream consumer late-binds a real value instead of the null that poisoned it (#731).
|
|
1165
|
+
emits.length > 0 ? { resume: { kind: "agent" as const, emits } } : undefined,
|
|
1166
|
+
);
|
|
1167
|
+
return [
|
|
1168
|
+
...head,
|
|
1169
|
+
` <bpmn:exclusiveGateway id="${el}_gate" name="producer contract met?" default="${el}_g1">`,
|
|
1170
|
+
` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
|
|
1171
|
+
` <bpmn:outgoing>${el}_g0</bpmn:outgoing>`,
|
|
1172
|
+
` <bpmn:outgoing>${el}_g1</bpmn:outgoing>`,
|
|
1173
|
+
" </bpmn:exclusiveGateway>",
|
|
1174
|
+
...contractEscalation,
|
|
1175
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming><bpmn:incoming>${el}_g2</bpmn:incoming></bpmn:endEvent>`,
|
|
1036
1176
|
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
1037
|
-
flow(`${el}_i1`, `${el}_task`, `${el}
|
|
1177
|
+
flow(`${el}_i1`, `${el}_task`, `${el}_gate`),
|
|
1178
|
+
` <bpmn:sequenceFlow id="${el}_g0" name="contract met" sourceRef="${el}_gate" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${proceedCondition}</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
1179
|
+
` <bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="${contractEsc}" />`,
|
|
1180
|
+
flow(`${el}_g2`, contractEsc, `${el}_end`),
|
|
1038
1181
|
flow(`${el}_i2`, `${el}_be`, esc),
|
|
1039
1182
|
flow(`${el}_i3`, esc, `${el}_end`),
|
|
1040
1183
|
];
|