@nanobpm/nano-workforce 0.180.0 → 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 +6 -0
- package/app/agentGuide.test.ts +87 -0
- package/app/agentGuide.ts +61 -0
- package/app/deliveryGraph.ts +1 -1
- 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/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,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.180.0](https://github.com/nanobpm/nano-workforce/compare/v0.179.2...v0.180.0) (2026-09-04)
|
|
2
8
|
|
|
3
9
|
### 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
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -184,3 +184,134 @@ test("sequenceIssues: more than the max issues → rejected", () => {
|
|
|
184
184
|
const issues = rejects({ issues: many });
|
|
185
185
|
assert(issues.some((i) => i.path === "issues"));
|
|
186
186
|
});
|
|
187
|
+
|
|
188
|
+
// ── Interleaved gates (issue #740) ──────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
test("sequenceIssues: the issue's example — an npm gate between two issues generates a wait[npm] node gating the agent on prior merge AND publish", () => {
|
|
191
|
+
const graph = ok({
|
|
192
|
+
issues: [
|
|
193
|
+
"nanobpm/nano-ide#557",
|
|
194
|
+
{ gate: { kind: "npm", target: "@nanobpm/agentic@0.13.0" }, issue: "jwulf/c8ctl-plugin-nano#186" },
|
|
195
|
+
"nanobpm/nano-workforce#738",
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
// 3 issues × 3 canonical nodes + 1 interleaved gate node = 10.
|
|
199
|
+
assertEquals(graph.nodes.length, 10);
|
|
200
|
+
const gate = graph.nodes.find((n) => n.id === "gate-2");
|
|
201
|
+
assert(gate, "the interleaved gate node must exist");
|
|
202
|
+
assertEquals(gate.kind, "wait");
|
|
203
|
+
assertEquals(gate.wait.kind, "npm");
|
|
204
|
+
assertEquals(gate.wait.target, "@nanobpm/agentic@0.13.0");
|
|
205
|
+
// The gate carries the bounded default budget (not the 30-min trap).
|
|
206
|
+
assertEquals(gate.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
|
|
207
|
+
assertEquals(gate.wait.onTimeout, "escalate");
|
|
208
|
+
// Gated on the prior merge: merged-1 → gate-2. Gated on the publish → the agent waits on the gate:
|
|
209
|
+
// gate-2 → open-2. Together the agent (open-2) starts only after issue-1 merged AND the npm publish.
|
|
210
|
+
assert(graph.edges.some((e) => e.from === "merged-1" && e.to === "gate-2"), "gate waits on the prior merge");
|
|
211
|
+
assert(graph.edges.some((e) => e.from === "gate-2" && e.to === "open-2"), "the agent waits on the gate");
|
|
212
|
+
// No direct merged-1 → open-2 edge — the gate is spliced in between.
|
|
213
|
+
assert(!graph.edges.some((e) => e.from === "merged-1" && e.to === "open-2"), "the gate replaces the direct sequence edge");
|
|
214
|
+
// The third (ungated) issue still sequences directly off the second's merge.
|
|
215
|
+
assert(graph.edges.some((e) => e.from === "merged-2" && e.to === "open-3"), "an ungated issue keeps the direct sequence edge");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("sequenceIssues: a gate on the FIRST issue behind an epic gate chains gate-epic → gate-1 → open-1", () => {
|
|
219
|
+
const graph = ok({
|
|
220
|
+
behind: "acme/repo#100",
|
|
221
|
+
issues: [{ gate: { kind: "github-check", target: "acme/repo@main" }, issue: "acme/repo#1" }],
|
|
222
|
+
});
|
|
223
|
+
assert(graph.nodes.some((n) => n.id === "gate-epic"), "the leading epic gate exists");
|
|
224
|
+
assert(graph.nodes.some((n) => n.id === "gate-1"), "the interleaved first-issue gate exists");
|
|
225
|
+
assert(graph.edges.some((e) => e.from === "gate-epic" && e.to === "gate-1"), "epic gate → interleaved gate");
|
|
226
|
+
assert(graph.edges.some((e) => e.from === "gate-1" && e.to === "open-1"), "interleaved gate → agent");
|
|
227
|
+
assert(!graph.edges.some((e) => e.from === "gate-epic" && e.to === "open-1"), "the interleaved gate is spliced before the agent");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("sequenceIssues: a gated first issue with NO behind gate starts the gate immediately (no predecessor edge)", () => {
|
|
231
|
+
const graph = ok({ issues: [{ gate: { kind: "npm", target: "pkg@1.0.0" }, issue: "acme/repo#1" }] });
|
|
232
|
+
// No inbound edge to gate-1 (nothing precedes it); the agent still waits on the gate.
|
|
233
|
+
assert(!graph.edges.some((e) => e.to === "gate-1"), "an ungated-first gate has no predecessor edge");
|
|
234
|
+
assert(graph.edges.some((e) => e.from === "gate-1" && e.to === "open-1"), "the agent waits on the gate");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("sequenceIssues: a gate's `kind`/`target` are trimmed before validation AND persistence (no whitespace-poisoned probe)", () => {
|
|
238
|
+
// Surrounding whitespace must neither trip a confusing "unknown kind" rejection nor survive into
|
|
239
|
+
// the generated `wait` node, where a stray trailing space silently probes the wrong target.
|
|
240
|
+
const graph = ok({ issues: [{ gate: { kind: " npm ", target: " pkg@1.0.0 " }, issue: "acme/repo#1" }] });
|
|
241
|
+
const gate = graph.nodes.find((n) => n.id === "gate-1");
|
|
242
|
+
assert(gate, "the interleaved gate node must exist");
|
|
243
|
+
assertEquals(gate.wait.kind, "npm");
|
|
244
|
+
assertEquals(gate.wait.target, "pkg@1.0.0");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("sequenceIssues: an object entry accepts optional gate fields (match, poll, onTimeout, credentialEnv)", () => {
|
|
248
|
+
const graph = ok({
|
|
249
|
+
issues: [
|
|
250
|
+
{
|
|
251
|
+
gate: {
|
|
252
|
+
kind: "http",
|
|
253
|
+
target: "https://example.test/ready",
|
|
254
|
+
match: { status: 200 },
|
|
255
|
+
poll: { everyMs: 1000, timeoutMs: 60000 },
|
|
256
|
+
onTimeout: "continue",
|
|
257
|
+
credentialEnv: "MY_TOKEN",
|
|
258
|
+
},
|
|
259
|
+
issue: "acme/repo#1",
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
const gate = graph.nodes.find((n) => n.id === "gate-1");
|
|
264
|
+
assertEquals(gate.wait.match, { status: 200 });
|
|
265
|
+
assertEquals(gate.wait.poll, { everyMs: 1000, timeoutMs: 60000 });
|
|
266
|
+
assertEquals(gate.wait.onTimeout, "continue");
|
|
267
|
+
assertEquals(gate.wait.credentialEnv, "MY_TOKEN");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("sequenceIssues: a bare-string entry is byte-for-byte identical to the object form without a gate", () => {
|
|
271
|
+
const bare = ok({ issues: ["acme/repo#1", "acme/repo#2"] });
|
|
272
|
+
const objs = ok({ issues: [{ issue: "acme/repo#1" }, { issue: "acme/repo#2" }] });
|
|
273
|
+
assertEquals(objs.nodes, bare.nodes);
|
|
274
|
+
assertEquals(objs.edges, bare.edges);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("sequenceIssues: a graph with an interleaved gate passes validateDeliveryGraph AND compiles", async () => {
|
|
278
|
+
const graph = ok({
|
|
279
|
+
issues: [
|
|
280
|
+
"acme/repo#1",
|
|
281
|
+
{ gate: { kind: "npm", target: "@scope/pkg@2.0.0" }, issue: "acme/repo#2" },
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
assertEquals(validateDeliveryGraph(graph), []);
|
|
285
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
286
|
+
assert(compiled.ok, `expected the gated graph to compile, got ${JSON.stringify(compiled)}`);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("sequenceIssues: an unknown gate kind → rejected at issues[i].gate.kind", () => {
|
|
290
|
+
const issues = rejects({ issues: [{ gate: { kind: "no-such-probe", target: "x" }, issue: "acme/repo#1" }] });
|
|
291
|
+
assert(issues.some((i) => i.path === "issues[0].gate.kind"), `expected issues[0].gate.kind, got ${JSON.stringify(issues)}`);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("sequenceIssues: a gate missing its target → rejected at issues[i].gate.target", () => {
|
|
295
|
+
const issues = rejects({ issues: [{ gate: { kind: "npm" }, issue: "acme/repo#1" }] });
|
|
296
|
+
assert(issues.some((i) => i.path === "issues[0].gate.target"), `expected issues[0].gate.target, got ${JSON.stringify(issues)}`);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("sequenceIssues: a gate with onTimeout:fail → rejected at issues[i].gate.onTimeout", () => {
|
|
300
|
+
const issues = rejects({ issues: [{ gate: { kind: "npm", target: "pkg@1", onTimeout: "fail" }, issue: "acme/repo#1" }] });
|
|
301
|
+
assert(issues.some((i) => i.path === "issues[0].gate.onTimeout"), `expected issues[0].gate.onTimeout, got ${JSON.stringify(issues)}`);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("sequenceIssues: an object entry missing `issue` → rejected at issues[i].issue", () => {
|
|
305
|
+
const issues = rejects({ issues: [{ gate: { kind: "npm", target: "pkg@1" } }] });
|
|
306
|
+
assert(issues.some((i) => i.path === "issues[0].issue"), `expected issues[0].issue, got ${JSON.stringify(issues)}`);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("sequenceIssues: a fully-gated max-length sequence behind an epic gate exceeds the node ceiling → rejected", () => {
|
|
310
|
+
const many = Array.from({ length: MAX_SEQUENCE_ISSUES }, (_, i) => ({
|
|
311
|
+
gate: { kind: "npm", target: `pkg@${i + 1}` },
|
|
312
|
+
issue: `acme/repo#${i + 1}`,
|
|
313
|
+
}));
|
|
314
|
+
// 64 × 4 nodes + 1 epic gate = 257 > 256.
|
|
315
|
+
const issues = rejects({ behind: "acme/repo#999", issues: many });
|
|
316
|
+
assert(issues.some((i) => i.path === "issues" && /too many nodes/.test(i.message)), `expected a node-ceiling rejection, got ${JSON.stringify(issues)}`);
|
|
317
|
+
});
|