@nanobpm/nano-workforce 0.180.0 → 0.182.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 +30 -1
- package/app/deliveryGraphCompiler.ts +39 -20
- package/app/deliveryGraphProposals.test.ts +60 -0
- package/app/deliveryGraphProposals.ts +61 -3
- package/app/deliveryGraphStage.ts +11 -1
- package/app/deliveryRunner.test.ts +120 -58
- package/app/deliveryRunner.ts +154 -55
- package/app/repoEnvelope.ts +73 -1
- package/app/sequenceIssues.test.ts +143 -1
- package/app/sequenceIssues.ts +246 -31
- package/e2e/addressable-guide.e2e.ts +43 -0
- package/openapi.yaml +310 -29
- package/operations/compileDeliveryGraph.test.ts +4 -0
- package/operations/dispatchDeliveryGraph.test.ts +50 -4
- package/operations/dispatchDeliveryGraph.ts +44 -27
- 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.182.0](https://github.com/nanobpm/nano-workforce/compare/v0.181.0...v0.182.0) (2026-09-04)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* stamp each agent node's repository for per-node delivery-graph isolation ([#742](https://github.com/nanobpm/nano-workforce/issues/742)) ([e8dcdfc](https://github.com/nanobpm/nano-workforce/commit/e8dcdfce2122bdb2a7e881a0900ca1f607d6d402)), closes [#739](https://github.com/nanobpm/nano-workforce/issues/739)
|
|
6
|
+
|
|
7
|
+
## [0.181.0](https://github.com/nanobpm/nano-workforce/compare/v0.180.0...v0.181.0) (2026-09-04)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* 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)
|
|
12
|
+
|
|
1
13
|
## [0.180.0](https://github.com/nanobpm/nano-workforce/compare/v0.179.2...v0.180.0) (2026-09-04)
|
|
2
14
|
|
|
3
15
|
### Features
|
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
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
// Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
|
|
20
20
|
// caller can point the author straight at the offending input.
|
|
21
21
|
|
|
22
|
+
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
22
23
|
import { isConvergeTarget } from "./convergeTargets.ts";
|
|
23
24
|
import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
|
|
25
|
+
import { isResolvableRepo } from "./repoEnvelope.ts";
|
|
24
26
|
|
|
25
27
|
/** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
|
|
26
28
|
* deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
|
|
@@ -94,6 +96,8 @@ export type DeliveryGraphErrorCode =
|
|
|
94
96
|
| "raw-converge-node"
|
|
95
97
|
| "merge-requires-converge"
|
|
96
98
|
| "converge-merge-type"
|
|
99
|
+
| "invalid-node-repository"
|
|
100
|
+
| "invalid-node-base-branch"
|
|
97
101
|
| "unbound-pr";
|
|
98
102
|
|
|
99
103
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
@@ -164,7 +168,7 @@ const GRAPH_NAME_MAX_LENGTH = 255;
|
|
|
164
168
|
* like the library import/save doors' `graphJson`, where the schema never touches the parsed value)
|
|
165
169
|
* would otherwise let an oversized-but-compilable graph reach the layout/compiler and be persisted —
|
|
166
170
|
* both violating the declared contract and exposing the import path to avoidable CPU/memory growth. */
|
|
167
|
-
const GRAPH_MAX_NODES = 256;
|
|
171
|
+
export const GRAPH_MAX_NODES = 256;
|
|
168
172
|
const GRAPH_MAX_EDGES = 1024;
|
|
169
173
|
const NODE_MAX_EMITS = 32;
|
|
170
174
|
|
|
@@ -428,6 +432,31 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
428
432
|
code: "merge-requires-converge",
|
|
429
433
|
});
|
|
430
434
|
}
|
|
435
|
+
// #739: an `agent` node may declare its OWN `repository` (`owner/repo`) + `baseBranch`, so a
|
|
436
|
+
// cross-repo graph provisions each cell's isolation envelope from that node's own repo (no
|
|
437
|
+
// uniform run-level repo, no `repoless`). Both are OPTIONAL (absent → the run-level fallback),
|
|
438
|
+
// but a PRESENT value must pass the SAME allowlists the dispatch door / `repoEnvelopeVars` apply
|
|
439
|
+
// — a plain `owner/repo` (no `.git`, no host/query chars) and a plausible git branch name — so a
|
|
440
|
+
// graph that bypassed OpenAPI shape validation cannot smuggle a malformed clone URL / ref past
|
|
441
|
+
// this gate into the per-node envelope. Rejected path-qualified rather than silently dropped.
|
|
442
|
+
if (kind === "agent" && config.repository !== undefined && !isResolvableRepo(config.repository)) {
|
|
443
|
+
errors.push({
|
|
444
|
+
path: `${path}.${configKey}.repository`,
|
|
445
|
+
message:
|
|
446
|
+
"`agent.repository`, when present, must be an `owner/repo` reference (no trailing `.git`, no " +
|
|
447
|
+
`host/query characters) — got ${JSON.stringify(config.repository)} (#739)`,
|
|
448
|
+
code: "invalid-node-repository",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (kind === "agent" && config.baseBranch !== undefined && (typeof config.baseBranch !== "string" || !isPlausibleBranchName(config.baseBranch))) {
|
|
452
|
+
errors.push({
|
|
453
|
+
path: `${path}.${configKey}.baseBranch`,
|
|
454
|
+
message:
|
|
455
|
+
"`agent.baseBranch`, when present, must be a plausible git branch name (no whitespace, shell " +
|
|
456
|
+
`metacharacters, leading \`-\`, \`..\`/\`//\`, etc.) — got ${JSON.stringify(config.baseBranch)} (#739)`,
|
|
457
|
+
code: "invalid-node-base-branch",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
431
460
|
// #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
|
|
432
461
|
// binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
|
|
433
462
|
if (typeof id === "string" && id.length > 0) {
|
|
@@ -45,6 +45,18 @@ import {
|
|
|
45
45
|
validateDeliveryGraph,
|
|
46
46
|
} from "./deliveryGraph.ts";
|
|
47
47
|
import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
|
|
48
|
+
import { AGENT_TASK_NS } from "./repoEnvelope.ts";
|
|
49
|
+
|
|
50
|
+
/** The task-header key that carries an `agent` node's DECLARED per-node repository spec (#739) into the
|
|
51
|
+
* compiled BPMN. It is a DIGEST-STABLE, env-free marker — pure graph content — so two graphs differing
|
|
52
|
+
* only in a node's declared `repository`/`baseBranch` content-address differently (they are different
|
|
53
|
+
* graphs), while the env-dependent parts of the real envelope (`cloneTimeoutMs`) and the run-level
|
|
54
|
+
* FALLBACK repo are NOT baked here (they are injected by the runner POST-digest, so the same graph in
|
|
55
|
+
* two environments / two runs still shares one id). The runner replaces this single marker header on
|
|
56
|
+
* every agent service task with the flattened EFFECTIVE `io.nanobpm.agentTask.*` envelope headers
|
|
57
|
+
* (declared ?? run-level), or strips it for an unresolved/`repoless` cell. The `__` prefix marks it as
|
|
58
|
+
* an internal marker the harness never reads. */
|
|
59
|
+
export const AGENT_REPO_SPEC_HEADER = `${AGENT_TASK_NS}.__repoSpec`;
|
|
48
60
|
|
|
49
61
|
/** The engine-native BODY every node kind delegates to (Decision 2 — the graph SCHEDULES, it does not
|
|
50
62
|
* re-implement execution). Each node compiles to an EMBEDDED `bpmn:subProcess` (call activities are a
|
|
@@ -1027,7 +1039,7 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
|
|
|
1027
1039
|
// as a required data dependency. A broken producer (returns `in_progress`, or omits a required
|
|
1028
1040
|
// emit) escalates AT this node instead of threading an incomplete result onward.
|
|
1029
1041
|
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);
|
|
1042
|
+
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate, agentRepoSpecHeaderLines(node));
|
|
1031
1043
|
}
|
|
1032
1044
|
case "connector":
|
|
1033
1045
|
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
|
|
@@ -1040,11 +1052,25 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
|
|
|
1040
1052
|
}
|
|
1041
1053
|
}
|
|
1042
1054
|
|
|
1055
|
+
/** Render the DECLARED per-node repository-spec marker task header (#739) for an `agent` node — a single
|
|
1056
|
+
* `<zeebe:taskHeaders>` block carrying {@link AGENT_REPO_SPEC_HEADER} with a compact JSON of the node's
|
|
1057
|
+
* DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY agent service
|
|
1058
|
+
* task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the runner has a
|
|
1059
|
+
* single, uniform anchor to replace with the effective envelope on every cell. Digest-stable and
|
|
1060
|
+
* env-free — only the declared values (pure graph content) appear here; the run-level fallback and the
|
|
1061
|
+
* env-dependent `cloneTimeoutMs` are injected by the runner POST-digest. Declared values pass the
|
|
1062
|
+
* `owner/repo` + branch-name allowlists (validator/OpenAPI), so the JSON carries no XML-hostile chars. */
|
|
1063
|
+
function agentRepoSpecHeaderLines(node: Extract<DeliveryNode, { kind: "agent" }>): string[] {
|
|
1064
|
+
const trimOrNull = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
|
1065
|
+
const spec = JSON.stringify({ repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
|
|
1066
|
+
return [
|
|
1067
|
+
" <zeebe:taskHeaders>",
|
|
1068
|
+
` <zeebe:header key="${AGENT_REPO_SPEC_HEADER}" ${attr("value", spec)} />`,
|
|
1069
|
+
" </zeebe:taskHeaders>",
|
|
1070
|
+
];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1043
1073
|
/** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
|
|
1044
|
-
* escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
|
|
1045
|
-
* `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
|
|
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
1074
|
* exclusive split's SUCCESS flow: the completion proceeds onward only when the self-reported `status`
|
|
1049
1075
|
* is a terminal success (or absent/null) AND every required-data-dependency emit is populated non-null.
|
|
1050
1076
|
* Reads the job's returned variables from the subProcess scope (the emit source var for an agent fact
|
|
@@ -1095,23 +1121,16 @@ function serviceBodyLines(
|
|
|
1095
1121
|
taskProps: readonly string[],
|
|
1096
1122
|
descriptor: string,
|
|
1097
1123
|
contractGate?: { requiredEmits: readonly DeliveryFact[] },
|
|
1124
|
+
taskHeaders: readonly string[] = [],
|
|
1098
1125
|
): string[] {
|
|
1099
1126
|
const esc = escalationTaskElement(el);
|
|
1100
|
-
const taskExt =
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
" </zeebe:properties>",
|
|
1108
|
-
" </bpmn:extensionElements>",
|
|
1109
|
-
]
|
|
1110
|
-
: [
|
|
1111
|
-
" <bpmn:extensionElements>",
|
|
1112
|
-
` <zeebe:taskDefinition ${taskDefAttr} />`,
|
|
1113
|
-
" </bpmn:extensionElements>",
|
|
1114
|
-
];
|
|
1127
|
+
const taskExt = [
|
|
1128
|
+
" <bpmn:extensionElements>",
|
|
1129
|
+
` <zeebe:taskDefinition ${taskDefAttr} />`,
|
|
1130
|
+
...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
|
|
1131
|
+
...taskHeaders,
|
|
1132
|
+
" </bpmn:extensionElements>",
|
|
1133
|
+
];
|
|
1115
1134
|
const timeoutEscalation = escalationTaskLines(
|
|
1116
1135
|
esc,
|
|
1117
1136
|
nodeId,
|
|
@@ -178,6 +178,47 @@ test("stageProposal: proposals with DIFFERENT logical keys coexist — supersede
|
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
// ── StageOutcome visibility (issue #740) ────────────────────────────────────────────────────────
|
|
182
|
+
test("stageProposal outcome: a FIRST stage reports nothing superseded and no live siblings", async () => {
|
|
183
|
+
await withData(async (data) => {
|
|
184
|
+
const outcome = await stageProposal(data, row({ digest: "d1" }));
|
|
185
|
+
assertEquals(outcome.row.digest, "d1");
|
|
186
|
+
assertEquals(outcome.superseded, []);
|
|
187
|
+
assertEquals(outcome.siblingsStaged, 0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("stageProposal outcome: a same-logical-key re-stage reports the retired digest in `superseded`", async () => {
|
|
192
|
+
await withData(async (data) => {
|
|
193
|
+
await stageProposal(data, row({ digest: "d1" }));
|
|
194
|
+
const outcome = await stageProposal(data, row({ digest: "d2" })); // same logical_key "runbook"
|
|
195
|
+
assertEquals(outcome.row.digest, "d2");
|
|
196
|
+
assertEquals(outcome.superseded, ["d1"], "the prior same-key staged digest was retired");
|
|
197
|
+
assertEquals(outcome.siblingsStaged, 0, "no OTHER-logical-key live proposal exists");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("stageProposal outcome: an idempotent re-stage of the SAME live digest supersedes nothing", async () => {
|
|
202
|
+
await withData(async (data) => {
|
|
203
|
+
await stageProposal(data, row({ digest: "d1", createdAt: "2999-01-01T00:00:00.000Z" }));
|
|
204
|
+
const outcome = await stageProposal(data, row({ digest: "d1", createdAt: "2999-01-01T00:00:00.000Z" }));
|
|
205
|
+
assertEquals(outcome.row.digest, "d1");
|
|
206
|
+
assertEquals(outcome.superseded, [], "re-staging the identical live digest retires nothing");
|
|
207
|
+
assertEquals(outcome.siblingsStaged, 0);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("stageProposal outcome: an orphan sibling under a DIFFERENT logical key is counted in `siblingsStaged`", async () => {
|
|
212
|
+
await withData(async (data) => {
|
|
213
|
+
// The footgun: a renamed graph stages under a new logical key without superseding the old one.
|
|
214
|
+
await stageProposal(data, row({ digest: "d1", logicalKey: "runbook-old" }));
|
|
215
|
+
const outcome = await stageProposal(data, row({ digest: "d2", logicalKey: "runbook-new" }));
|
|
216
|
+
assertEquals(outcome.row.digest, "d2");
|
|
217
|
+
assertEquals(outcome.superseded, [], "a different logical key supersedes nothing");
|
|
218
|
+
assertEquals(outcome.siblingsStaged, 1, "the still-live orphan under the old key is visible");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
181
222
|
test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage whose supersede pass runs AFTER a newer stage neither clobbers it (zero) nor coexists with it (two)", async () => {
|
|
182
223
|
await withData(async (data) => {
|
|
183
224
|
const table = deliveryGraphProposals(data);
|
|
@@ -200,6 +241,25 @@ test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage
|
|
|
200
241
|
});
|
|
201
242
|
});
|
|
202
243
|
|
|
244
|
+
test("stageProposal outcome: `row` reflects the POST-reconcile status — a stage immediately superseded by a newer sibling reports its own row as `superseded`, not the pre-reconcile `staged`", async () => {
|
|
245
|
+
await withData(async (data) => {
|
|
246
|
+
const table = deliveryGraphProposals(data);
|
|
247
|
+
// A newer staged sibling (d2) is already committed for logical_key "runbook".
|
|
248
|
+
const newer = row({ digest: "d2" });
|
|
249
|
+
newer.updated_at = "2999-01-01T00:00:00.000Z";
|
|
250
|
+
await table.insert(newer);
|
|
251
|
+
// The older stage (d1) runs last; the reconcile immediately supersedes its own row.
|
|
252
|
+
const outcome = await stageProposal(data, row({ digest: "d1" }));
|
|
253
|
+
assertEquals(outcome.row.digest, "d1");
|
|
254
|
+
assertEquals(
|
|
255
|
+
outcome.row.status,
|
|
256
|
+
"superseded",
|
|
257
|
+
"the returned row is re-read post-reconcile, matching what is actually persisted",
|
|
258
|
+
);
|
|
259
|
+
assertEquals((await table.get("d1"))?.status, "superseded");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
203
263
|
test("getStagedProposal: an EXPIRED staged proposal is not live", async () => {
|
|
204
264
|
await withData(async (data) => {
|
|
205
265
|
await stageProposal(data, row());
|
|
@@ -153,6 +153,27 @@ export function buildProposalRow(input: {
|
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** The outcome of a {@link stageProposal} — the written row PLUS a supersede/sibling summary the stage
|
|
157
|
+
* doors surface so an agent can warn the operator precisely about what its stage did to the cockpit's
|
|
158
|
+
* Delivery Graphs list (issue #740). Supersede keys on the LOGICAL graph key (derived from the graph
|
|
159
|
+
* `name`), so re-staging the "same" runbook under a CHANGED `name` creates a sibling with a different
|
|
160
|
+
* logical key that is NOT superseded — a silent footgun. Making the collision VISIBLE here (rather than
|
|
161
|
+
* silent) lets the agent tell the operator exactly which digests it retired and how many other live
|
|
162
|
+
* proposals remain (potential orphaned siblings). */
|
|
163
|
+
export interface StageOutcome {
|
|
164
|
+
/** The row this stage wrote, RE-READ after the supersede reconcile so it reflects the actually-
|
|
165
|
+
* persisted state (a concurrent newer stage can leave this digest `superseded` immediately). */
|
|
166
|
+
row: DeliveryGraphProposal;
|
|
167
|
+
/** Digests of OTHER proposals sharing this row's logical key that this stage flipped to
|
|
168
|
+
* `superseded` — the same-logical-graph proposals it cleanly replaced. Empty on a first stage. */
|
|
169
|
+
superseded: string[];
|
|
170
|
+
/** How many OTHER live `staged` proposals remain after this stage (a DIFFERENT logical key from this
|
|
171
|
+
* row) — i.e. proposals this stage did NOT supersede. A non-zero count flags possible orphaned
|
|
172
|
+
* siblings (e.g. an earlier stage of the same runbook under a different `name`) cluttering the
|
|
173
|
+
* operator's list, so the agent can name them for cleanup. */
|
|
174
|
+
siblingsStaged: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
156
177
|
/** Persist a compiled graph as a `staged` proposal and SUPERSEDE any prior staged proposal for the
|
|
157
178
|
* same logical graph. Idempotent on `digest` (a re-stage of an identical, still-live digest refreshes
|
|
158
179
|
* `updated_at` but preserves `created_at`, so the TTL stays anchored to the first stage; a re-stage of
|
|
@@ -162,10 +183,20 @@ export function buildProposalRow(input: {
|
|
|
162
183
|
* exactly one live proposal per logical graph (the latest digest the operator would dispatch). The
|
|
163
184
|
* supersede RECONCILES to the globally-newest staged row (`updated_at`, `digest`-tie-broken) rather than
|
|
164
185
|
* flipping only rows older than the just-written one, so concurrent stages of two different digests
|
|
165
|
-
* converge to EXACTLY ONE live proposal — never zero, and never two — regardless of arrival order.
|
|
166
|
-
|
|
186
|
+
* converge to EXACTLY ONE live proposal — never zero, and never two — regardless of arrival order.
|
|
187
|
+
*
|
|
188
|
+
* Returns a {@link StageOutcome} — the written row plus the supersede/sibling summary (issue #740) the
|
|
189
|
+
* stage doors surface so an agent can warn the operator precisely about siblings it did / did not
|
|
190
|
+
* retire. */
|
|
191
|
+
export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<StageOutcome> {
|
|
167
192
|
const table = deliveryGraphProposals(data);
|
|
168
193
|
const existing = await table.get(row.digest);
|
|
194
|
+
// Capture the same-logical-key staged siblings that exist BEFORE this stage writes (excluding our own
|
|
195
|
+
// digest) — the supersede reconcile below normally flips them all to `superseded`; we re-read them
|
|
196
|
+
// after to report exactly which digests this stage retired.
|
|
197
|
+
const priorSameKey = (await table.find({ status: "staged" })).filter(
|
|
198
|
+
(r) => r.logical_key === row.logical_key && r.digest !== row.digest && isLiveStaged(r),
|
|
199
|
+
);
|
|
169
200
|
const toWrite = existing
|
|
170
201
|
? buildProposalRow({
|
|
171
202
|
digest: row.digest,
|
|
@@ -208,7 +239,34 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
|
|
|
208
239
|
`UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."digest" > "delivery_graph_proposals"."digest")))`,
|
|
209
240
|
[now(), row.logical_key],
|
|
210
241
|
);
|
|
211
|
-
|
|
242
|
+
|
|
243
|
+
// Report what the stage did to siblings (issue #740). `superseded` = the same-logical-key proposals
|
|
244
|
+
// this stage retired (re-read post-reconcile so a concurrent newer stage that kept ITS row live —
|
|
245
|
+
// leaving ours superseded — is reported honestly). `siblingsStaged` = OTHER live staged proposals
|
|
246
|
+
// with a DIFFERENT logical key that remain (the orphaned-sibling footgun: a re-stage under a changed
|
|
247
|
+
// `name` never supersedes them).
|
|
248
|
+
// Re-check ONLY the pre-stage staged siblings captured above (typically zero or a handful of
|
|
249
|
+
// concurrently-live same-key rows), reporting exactly those this reconcile flipped to `superseded`.
|
|
250
|
+
// Re-reading each `priorSameKey` digest keeps staging O(live same-key siblings) — a global
|
|
251
|
+
// `find({ status: "superseded" })` scans EVERY superseded row ever written (proposals are never
|
|
252
|
+
// deleted) and grows without bound. Matching strictly on `status === "superseded"` (not merely
|
|
253
|
+
// `!== "staged"`) avoids misreporting a sibling that concurrently went `dispatched`/`dismissed`/
|
|
254
|
+
// `expired`.
|
|
255
|
+
const superseded: string[] = [];
|
|
256
|
+
for (const prev of priorSameKey) {
|
|
257
|
+
const after = await table.get(prev.digest);
|
|
258
|
+
if (after?.status === "superseded") superseded.push(prev.digest);
|
|
259
|
+
}
|
|
260
|
+
const siblingsStaged = (await listStagedProposals(data)).filter((r) => r.logical_key !== row.logical_key).length;
|
|
261
|
+
|
|
262
|
+
// Re-read the just-written row AFTER the reconcile so `row` reports the ACTUALLY-persisted state, not
|
|
263
|
+
// the pre-reconcile `toWrite`: a concurrent newer stage of a different digest can flip THIS digest to
|
|
264
|
+
// `superseded` in the reconcile above, so returning `toWrite` (still `staged`) would report a status
|
|
265
|
+
// that doesn't match the DB. Fall back to `toWrite` only if the row somehow vanished (it shouldn't —
|
|
266
|
+
// we just wrote it), keeping the return non-null.
|
|
267
|
+
const persisted = (await table.get(toWrite.digest)) ?? toWrite;
|
|
268
|
+
|
|
269
|
+
return { row: persisted, superseded, siblingsStaged };
|
|
212
270
|
}
|
|
213
271
|
|
|
214
272
|
/** The ONE definition of "a live, dispatchable-RIGHT-NOW staged proposal" (issue #608): the row is
|
|
@@ -39,6 +39,10 @@ export interface StagedResult {
|
|
|
39
39
|
nodeCount: number;
|
|
40
40
|
humanNodeCount: number;
|
|
41
41
|
sideEffectCount: number;
|
|
42
|
+
/** Digests of same-logical-graph proposals this stage superseded (issue #740). */
|
|
43
|
+
superseded: string[];
|
|
44
|
+
/** How many OTHER live staged proposals remain after this stage — potential orphaned siblings. */
|
|
45
|
+
siblingsStaged: number;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/** A rejected compile — the `CompileDeliveryGraphErrors` body, verbatim from the compiler. */
|
|
@@ -75,7 +79,7 @@ export async function compileAndStageDeliveryGraph(
|
|
|
75
79
|
? result.resolved.name.trim()
|
|
76
80
|
: null;
|
|
77
81
|
const preview = buildProposalPreview(result);
|
|
78
|
-
await stageProposal(
|
|
82
|
+
const outcome = await stageProposal(
|
|
79
83
|
data,
|
|
80
84
|
buildProposalRow({
|
|
81
85
|
digest,
|
|
@@ -99,10 +103,16 @@ export async function compileAndStageDeliveryGraph(
|
|
|
99
103
|
digest,
|
|
100
104
|
preview,
|
|
101
105
|
reviewUrl: proposalReviewUrl(digest, origin),
|
|
106
|
+
// Supersede/sibling visibility (issue #740): the agent surfaces these so the operator learns
|
|
107
|
+
// precisely which prior proposals this stage retired and whether other live proposals remain.
|
|
108
|
+
superseded: outcome.superseded,
|
|
109
|
+
siblingsStaged: outcome.siblingsStaged,
|
|
102
110
|
},
|
|
103
111
|
digest,
|
|
104
112
|
nodeCount: result.resolved.nodes.length,
|
|
105
113
|
humanNodeCount: result.humanNodes.length,
|
|
106
114
|
sideEffectCount: result.sideEffects.length,
|
|
115
|
+
superseded: outcome.superseded,
|
|
116
|
+
siblingsStaged: outcome.siblingsStaged,
|
|
107
117
|
};
|
|
108
118
|
}
|