@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/app/sequenceIssues.ts
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// `pr`-typed emits, threaded fact edges, a DAG. It is validated against the S3 vocabulary
|
|
20
20
|
// (`deliveryGraphVocabulary`) so an unknown connector target / probe kind is rejected at the door with
|
|
21
21
|
// `issues[{path,message}]` rather than only surfacing at compile time.
|
|
22
|
-
import type { DeliveryEdge, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
22
|
+
import type { DeliveryEdge, DeliveryGraph, DeliveryNode, ReadinessProbe } from "../nano-generated/api-io.d.ts";
|
|
23
23
|
import { CONVERGE_MERGE_TARGET } from "./convergeTargets.ts";
|
|
24
|
+
import { GRAPH_MAX_NODES } from "./deliveryGraph.ts";
|
|
24
25
|
import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
|
|
25
26
|
import { type ParsedIssue, parseIssue } from "./plan.ts";
|
|
26
27
|
|
|
@@ -31,10 +32,42 @@ export interface SequenceIssueError {
|
|
|
31
32
|
readonly message: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
+
/** An interleaved `wait` GATE that must go green before a given issue's agent starts (issue #740).
|
|
36
|
+
* Reuses the exact probe schema `wait` nodes already accept — the `npm`/`github-check`/`http`/…
|
|
37
|
+
* vocabulary — so an author can insert e.g. "wait for `@nanobpm/agentic@0.13.0` to publish" between
|
|
38
|
+
* two sequence steps without falling off the intent onto raw node/edge JSON. */
|
|
39
|
+
export interface SequenceGate {
|
|
40
|
+
/** The wait-probe kind (must be a known kind in the S3 vocabulary — `npm`, `github-check`, `http`,
|
|
41
|
+
* `command`, `capability`, `pr`, `epic`). */
|
|
42
|
+
kind: string;
|
|
43
|
+
/** The probe target — kind-specific (`pkg@version` for `npm`, `owner/repo@ref` for `github-check`,
|
|
44
|
+
* a URL for `http`, …). */
|
|
45
|
+
target: string;
|
|
46
|
+
/** Optional kind-specific readiness `match` fields (e.g. `{ version }` for `npm`). */
|
|
47
|
+
match?: Record<string, unknown>;
|
|
48
|
+
/** Optional poll budget; defaults to the bounded merge-gate budget ({@link MERGE_POLL}) so a gate
|
|
49
|
+
* never falls into the 30-minute default trap. */
|
|
50
|
+
poll?: { everyMs?: number; timeoutMs?: number };
|
|
51
|
+
/** What to do when the gate never goes green within its budget — `escalate` (default) or `continue`.
|
|
52
|
+
* `fail` is rejected (not yet supported by the compiler/engine). */
|
|
53
|
+
onTimeout?: "escalate" | "continue";
|
|
54
|
+
/** Optional env-key name supplying a credential for the probe (`http`/`capability`). */
|
|
55
|
+
credentialEnv?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One `issues[]` entry with an OPTIONAL leading {@link SequenceGate} — the object form. A bare string
|
|
59
|
+
* entry (no gate) keeps today's behaviour byte-for-byte. */
|
|
60
|
+
export interface SequenceIssueEntry {
|
|
61
|
+
gate?: SequenceGate;
|
|
62
|
+
issue: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `sequenceIssues` intent body — an optional leading `behind` gate plus the ordered `issues`,
|
|
66
|
+
* each of which may be a bare `owner/repo#N` string OR a `{ gate?, issue }` object interleaving a
|
|
67
|
+
* `wait` gate before that issue's agent (issue #740). */
|
|
35
68
|
export interface SequenceIssuesIntent {
|
|
36
69
|
behind?: string;
|
|
37
|
-
issues: string[];
|
|
70
|
+
issues: (string | SequenceIssueEntry)[];
|
|
38
71
|
}
|
|
39
72
|
|
|
40
73
|
/** The result of {@link buildSequenceGraph}: either the constructed graph, or the path-qualified
|
|
@@ -63,6 +96,29 @@ export const MAX_SEQUENCE_ISSUES = 64;
|
|
|
63
96
|
* downstream connector / `wait[pr]` node late-binds its target PR from the fact (§9.4, issue #548). */
|
|
64
97
|
const PR_EMIT = { name: "pr", type: "pr" as const };
|
|
65
98
|
|
|
99
|
+
/** A validated, ready-to-emit interleaved gate: `poll` and `onTimeout` are resolved to concrete values
|
|
100
|
+
* (defaults applied) so {@link assembleGraph} can drop it straight into a `wait` node's config. */
|
|
101
|
+
interface BuiltGate {
|
|
102
|
+
kind: ReadinessProbe["kind"];
|
|
103
|
+
target: string;
|
|
104
|
+
match?: Record<string, unknown>;
|
|
105
|
+
poll: { everyMs?: number; timeoutMs?: number };
|
|
106
|
+
onTimeout: "escalate" | "continue";
|
|
107
|
+
credentialEnv?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A parsed `issues[]` entry: the normalised issue plan-key plus its optional interleaved gate. */
|
|
111
|
+
interface ParsedEntry {
|
|
112
|
+
issueKey: string;
|
|
113
|
+
gate: BuiltGate | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Narrow a validated string to the closed `ReadinessProbe["kind"]` union — backed by the runtime
|
|
117
|
+
* vocabulary set so the type guard and the door validation share one source of truth. */
|
|
118
|
+
function isProbeKind(kind: string, probeKinds: Set<string>): kind is ReadinessProbe["kind"] {
|
|
119
|
+
return probeKinds.has(kind);
|
|
120
|
+
}
|
|
121
|
+
|
|
66
122
|
/** Parse a ref into an issue target ONLY if its number is a positive, safe integer. `parseIssue`'s
|
|
67
123
|
* `\d+` accepts `#0` and precision-overflowing numbers (e.g. `#99999999999999999999`, which coerces
|
|
68
124
|
* past `Number.MAX_SAFE_INTEGER`), but such a target can never resolve to a real issue/PR — staging a
|
|
@@ -74,12 +130,111 @@ function parseIssueRef(ref: unknown): ParsedIssue | null {
|
|
|
74
130
|
return Number.isSafeInteger(parsed.number) && parsed.number >= 1 ? parsed : null;
|
|
75
131
|
}
|
|
76
132
|
|
|
133
|
+
/** Validate one interleaved `gate` spec against the probe vocabulary, applying defaults. Returns the
|
|
134
|
+
* built gate or path-qualified `issues[{path,message}]` rejections (keyed under `path`, e.g.
|
|
135
|
+
* `issues[1].gate.kind`). Reuses the same probe schema `wait` nodes accept: `{ kind, target, match?,
|
|
136
|
+
* poll?, onTimeout?, credentialEnv? }`. */
|
|
137
|
+
function parseGate(raw: unknown, path: string, probeKinds: Set<string>): { gate?: BuiltGate; errors: SequenceIssueError[] } {
|
|
138
|
+
const errors: SequenceIssueError[] = [];
|
|
139
|
+
if (!isRecord(raw)) {
|
|
140
|
+
return { errors: [{ path, message: "`gate` must be an object carrying a `wait` probe (`{ kind, target, … }`)." }] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Trim before validating AND before persisting: harmless surrounding whitespace must neither
|
|
144
|
+
// trip a confusing "unknown kind" rejection nor survive into the generated `wait` node, where a
|
|
145
|
+
// stray trailing space silently probes the wrong target (`pkg@1.0.0 ` → a gate that never goes
|
|
146
|
+
// green). The persisted value is always the trimmed one.
|
|
147
|
+
let validKind: ReadinessProbe["kind"] | null = null;
|
|
148
|
+
const kind = raw.kind;
|
|
149
|
+
if (typeof kind !== "string" || kind.trim() === "") {
|
|
150
|
+
errors.push({ path: `${path}.kind`, message: "`gate.kind` is required and must be a non-empty wait-probe kind." });
|
|
151
|
+
} else {
|
|
152
|
+
const trimmedKind = kind.trim();
|
|
153
|
+
if (!isProbeKind(trimmedKind, probeKinds)) {
|
|
154
|
+
errors.push({ path: `${path}.kind`, message: `wait-probe kind \`${trimmedKind}\` is not in the delivery-graph vocabulary.` });
|
|
155
|
+
} else {
|
|
156
|
+
validKind = trimmedKind;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let validTarget: string | null = null;
|
|
161
|
+
const target = raw.target;
|
|
162
|
+
if (typeof target !== "string" || target.trim() === "") {
|
|
163
|
+
errors.push({ path: `${path}.target`, message: "`gate.target` is required and must be a non-empty string." });
|
|
164
|
+
} else {
|
|
165
|
+
validTarget = target.trim();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let match: Record<string, unknown> | undefined;
|
|
169
|
+
if (raw.match !== undefined) {
|
|
170
|
+
if (!isRecord(raw.match)) {
|
|
171
|
+
errors.push({ path: `${path}.match`, message: "`gate.match`, when present, must be an object of readiness fields." });
|
|
172
|
+
} else {
|
|
173
|
+
match = raw.match;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const poll: { everyMs?: number; timeoutMs?: number } = { ...MERGE_POLL };
|
|
178
|
+
if (raw.poll !== undefined) {
|
|
179
|
+
if (!isRecord(raw.poll)) {
|
|
180
|
+
errors.push({ path: `${path}.poll`, message: "`gate.poll`, when present, must be `{ everyMs?, timeoutMs? }`." });
|
|
181
|
+
} else {
|
|
182
|
+
for (const key of ["everyMs", "timeoutMs"] as const) {
|
|
183
|
+
const v = raw.poll[key];
|
|
184
|
+
if (v === undefined) continue;
|
|
185
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 1) {
|
|
186
|
+
errors.push({ path: `${path}.poll.${key}`, message: `\`gate.poll.${key}\`, when present, must be a positive integer (milliseconds).` });
|
|
187
|
+
} else {
|
|
188
|
+
poll[key] = v;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let onTimeout: "escalate" | "continue" = "escalate";
|
|
195
|
+
if (raw.onTimeout !== undefined) {
|
|
196
|
+
if (raw.onTimeout !== "escalate" && raw.onTimeout !== "continue") {
|
|
197
|
+
errors.push({
|
|
198
|
+
path: `${path}.onTimeout`,
|
|
199
|
+
message: "`gate.onTimeout` must be `escalate` (default) or `continue` — `fail` is not supported on a `wait` node.",
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
onTimeout = raw.onTimeout;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let credentialEnv: string | undefined;
|
|
207
|
+
if (raw.credentialEnv !== undefined) {
|
|
208
|
+
if (typeof raw.credentialEnv !== "string" || raw.credentialEnv.trim() === "") {
|
|
209
|
+
errors.push({ path: `${path}.credentialEnv`, message: "`gate.credentialEnv`, when present, must be a non-empty env-key name." });
|
|
210
|
+
} else {
|
|
211
|
+
credentialEnv = raw.credentialEnv;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (errors.length > 0 || validKind === null || validTarget === null) return { errors };
|
|
216
|
+
return {
|
|
217
|
+
gate: {
|
|
218
|
+
kind: validKind,
|
|
219
|
+
target: validTarget,
|
|
220
|
+
...(match ? { match } : {}),
|
|
221
|
+
poll,
|
|
222
|
+
onTimeout,
|
|
223
|
+
...(credentialEnv ? { credentialEnv } : {}),
|
|
224
|
+
},
|
|
225
|
+
errors: [],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
77
229
|
/**
|
|
78
230
|
* Build the canonical delivery graph for a `sequenceIssues` intent, or return path-qualified
|
|
79
231
|
* `issues[{path,message}]` rejections for invalid input. Validates:
|
|
80
232
|
* - `issues` is a non-empty array within {@link MAX_SEQUENCE_ISSUES};
|
|
81
|
-
* - every `issues[i]`
|
|
82
|
-
*
|
|
233
|
+
* - every `issues[i]` (a bare ref, or a `{ gate?, issue }` object) and the optional `behind` parse
|
|
234
|
+
* as an `owner/repo#N` reference;
|
|
235
|
+
* - each interleaved `gate` names a wait-probe kind known to the S3 vocabulary (issue #740);
|
|
236
|
+
* - the connector target and wait-probe kinds it emits are known to the S3 vocabulary (drift guard);
|
|
237
|
+
* - the generated graph stays within the compiler's node ceiling.
|
|
83
238
|
* Pure — no I/O. The constructed graph passes `validateDeliveryGraph` by construction.
|
|
84
239
|
*/
|
|
85
240
|
export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
@@ -89,8 +244,12 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
89
244
|
const rawIssues = body.issues;
|
|
90
245
|
const rawBehind = body.behind;
|
|
91
246
|
|
|
92
|
-
//
|
|
93
|
-
const
|
|
247
|
+
// The vocabulary is needed up front to validate interleaved gate `kind`s as each entry is parsed.
|
|
248
|
+
const vocab = deliveryGraphVocabulary();
|
|
249
|
+
const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
|
|
250
|
+
|
|
251
|
+
// ── `issues`: a non-empty, bounded array of parseable refs, each optionally carrying a gate ────
|
|
252
|
+
const parsedEntries: ParsedEntry[] = [];
|
|
94
253
|
if (!Array.isArray(rawIssues)) {
|
|
95
254
|
issues.push({ path: "issues", message: "`issues` must be a non-empty array of `owner/repo#N` issue references." });
|
|
96
255
|
} else if (rawIssues.length === 0) {
|
|
@@ -101,16 +260,26 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
101
260
|
message: `\`issues\` has too many entries (${rawIssues.length}) — the limit is ${MAX_SEQUENCE_ISSUES}.`,
|
|
102
261
|
});
|
|
103
262
|
} else {
|
|
104
|
-
rawIssues.forEach((
|
|
105
|
-
|
|
263
|
+
rawIssues.forEach((entry, i) => {
|
|
264
|
+
// A bare string entry is a gate-less issue (today's behaviour, byte-for-byte). An object entry
|
|
265
|
+
// carries a required `issue` ref plus an OPTIONAL leading `gate` (issue #740).
|
|
266
|
+
const rawRef = isRecord(entry) ? entry.issue : entry;
|
|
267
|
+
const parsed = parseIssueRef(rawRef);
|
|
106
268
|
if (!parsed) {
|
|
269
|
+
const shown = isRecord(entry) ? String(rawRef) : String(entry);
|
|
107
270
|
issues.push({
|
|
108
|
-
path: `issues[${i}]`,
|
|
109
|
-
message: `\`${
|
|
271
|
+
path: isRecord(entry) ? `issues[${i}].issue` : `issues[${i}]`,
|
|
272
|
+
message: `\`${shown}\` is not a valid \`owner/repo#N\` issue reference.`,
|
|
110
273
|
});
|
|
111
274
|
return;
|
|
112
275
|
}
|
|
113
|
-
|
|
276
|
+
let gate: BuiltGate | null = null;
|
|
277
|
+
if (isRecord(entry) && entry.gate !== undefined && entry.gate !== null) {
|
|
278
|
+
const parsedGate = parseGate(entry.gate, `issues[${i}].gate`, probeKinds);
|
|
279
|
+
issues.push(...parsedGate.errors);
|
|
280
|
+
gate = parsedGate.gate ?? null;
|
|
281
|
+
}
|
|
282
|
+
parsedEntries.push({ issueKey: parsed.planKey, gate });
|
|
114
283
|
});
|
|
115
284
|
}
|
|
116
285
|
|
|
@@ -135,7 +304,6 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
135
304
|
// ── Vocabulary drift guard (S3): the target/probe kinds this generator emits MUST be known to the
|
|
136
305
|
// structured vocabulary. This can only trip if the closed vocabulary changes underneath us — it is
|
|
137
306
|
// surfaced as a door `issue` (not a throw) so the failure mode is a clean rejection, not a 500. ──
|
|
138
|
-
const vocab = deliveryGraphVocabulary();
|
|
139
307
|
const realTargets = new Set(vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target));
|
|
140
308
|
if (!realTargets.has(CONVERGE_MERGE_TARGET)) {
|
|
141
309
|
issues.push({
|
|
@@ -143,28 +311,43 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
143
311
|
message: `connector target \`${CONVERGE_MERGE_TARGET}\` is not a real target in the delivery-graph vocabulary.`,
|
|
144
312
|
});
|
|
145
313
|
}
|
|
146
|
-
const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
|
|
147
314
|
for (const kind of behindKey ? [PR_PROBE_KIND, EPIC_PROBE_KIND] : [PR_PROBE_KIND]) {
|
|
148
315
|
if (!probeKinds.has(kind)) {
|
|
149
316
|
issues.push({ path: "issues", message: `wait-probe kind \`${kind}\` is not in the delivery-graph vocabulary.` });
|
|
150
317
|
}
|
|
151
318
|
}
|
|
152
319
|
|
|
320
|
+
// ── Node-budget guard: interleaved gates add a node per gated issue, so a fully-gated max-length
|
|
321
|
+
// sequence (plus the optional `behind` gate) can push past the compiler's node ceiling. Reject it
|
|
322
|
+
// at the door so a SUCCESSFULLY generated graph always validates `by construction`. ──
|
|
323
|
+
if (issues.length === 0) {
|
|
324
|
+
const projectedNodes = (behindKey ? 1 : 0) + parsedEntries.reduce((n, e) => n + 3 + (e.gate ? 1 : 0), 0);
|
|
325
|
+
if (projectedNodes > GRAPH_MAX_NODES) {
|
|
326
|
+
issues.push({
|
|
327
|
+
path: "issues",
|
|
328
|
+
message: `the generated graph would have too many nodes (${projectedNodes}) — the limit is ${GRAPH_MAX_NODES}; use fewer issues or interleaved gates.`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
153
333
|
if (issues.length > 0) return { ok: false, issues };
|
|
154
334
|
|
|
155
|
-
return { ok: true, graph: assembleGraph(
|
|
335
|
+
return { ok: true, graph: assembleGraph(parsedEntries, behindKey) };
|
|
156
336
|
}
|
|
157
337
|
|
|
158
|
-
/** Assemble the canonical node/edge chain for the (already-validated)
|
|
159
|
-
|
|
338
|
+
/** Assemble the canonical node/edge chain for the (already-validated) parsed entries + optional gate.
|
|
339
|
+
* Each entry emits `agent → connector[converge-merge] → wait[pr, merged]`; an entry with an
|
|
340
|
+
* interleaved `gate` gets a leading `wait[<gate.kind>]` node spliced between the prior sequence step
|
|
341
|
+
* and its agent, so the agent starts only once the prior issue merged AND the gate went green. */
|
|
342
|
+
function assembleGraph(entries: ParsedEntry[], behindKey: string | null): DeliveryGraph {
|
|
160
343
|
const nodes: DeliveryNode[] = [];
|
|
161
344
|
const edges: DeliveryEdge[] = [];
|
|
162
345
|
|
|
163
346
|
// Optional leading `wait[epic]` gate — the whole sequence waits for `behind` to be fully merged.
|
|
164
|
-
const
|
|
347
|
+
const EPIC_GATE_ID = "gate-epic";
|
|
165
348
|
if (behindKey) {
|
|
166
349
|
nodes.push({
|
|
167
|
-
id:
|
|
350
|
+
id: EPIC_GATE_ID,
|
|
168
351
|
kind: "wait",
|
|
169
352
|
wait: {
|
|
170
353
|
kind: EPIC_PROBE_KIND,
|
|
@@ -177,12 +360,40 @@ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryG
|
|
|
177
360
|
});
|
|
178
361
|
}
|
|
179
362
|
|
|
180
|
-
|
|
363
|
+
entries.forEach((entry, i) => {
|
|
181
364
|
const n = i + 1;
|
|
182
365
|
const openId = `open-${n}`;
|
|
183
366
|
const landId = `land-${n}`;
|
|
184
367
|
const mergedId = `merged-${n}`;
|
|
185
368
|
const prRef = `${openId}.pr`;
|
|
369
|
+
const issueKey = entry.issueKey;
|
|
370
|
+
|
|
371
|
+
// The sequence predecessor whose completion releases THIS issue: the prior issue's merge, else the
|
|
372
|
+
// leading epic gate for the first issue (null when the first issue is ungated by `behind`).
|
|
373
|
+
const predecessor = i === 0 ? (behindKey ? EPIC_GATE_ID : null) : `merged-${i}`;
|
|
374
|
+
|
|
375
|
+
// Interleaved gate (issue #740): a `wait[<kind>]` node the issue's agent waits on. It inherits the
|
|
376
|
+
// sequence predecessor's edge (so it only starts probing once the prior issue merged) and the
|
|
377
|
+
// agent then waits on the gate — gating the agent on the prior merge AND the gate condition.
|
|
378
|
+
let agentPredecessor = predecessor;
|
|
379
|
+
if (entry.gate) {
|
|
380
|
+
const gateId = `gate-${n}`;
|
|
381
|
+
const g = entry.gate;
|
|
382
|
+
nodes.push({
|
|
383
|
+
id: gateId,
|
|
384
|
+
kind: "wait",
|
|
385
|
+
wait: {
|
|
386
|
+
kind: g.kind,
|
|
387
|
+
target: g.target,
|
|
388
|
+
...(g.match ? { match: g.match } : {}),
|
|
389
|
+
poll: { ...g.poll },
|
|
390
|
+
onTimeout: g.onTimeout,
|
|
391
|
+
...(g.credentialEnv ? { credentialEnv: g.credentialEnv } : {}),
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
if (predecessor) edges.push({ from: predecessor, to: gateId });
|
|
395
|
+
agentPredecessor = gateId;
|
|
396
|
+
}
|
|
186
397
|
|
|
187
398
|
// agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind.
|
|
188
399
|
nodes.push({
|
|
@@ -214,18 +425,16 @@ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryG
|
|
|
214
425
|
edges.push({ from: prRef, to: landId });
|
|
215
426
|
edges.push({ from: prRef, to: mergedId });
|
|
216
427
|
|
|
217
|
-
// Sequence: this issue's agent starts once
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
} else {
|
|
221
|
-
edges.push({ from: `merged-${i}`, to: openId });
|
|
222
|
-
}
|
|
428
|
+
// Sequence: this issue's agent starts once its predecessor (prior merge / epic gate / interleaved
|
|
429
|
+
// gate) released. A first ungated issue has no predecessor edge (it starts immediately).
|
|
430
|
+
if (agentPredecessor) edges.push({ from: agentPredecessor, to: openId });
|
|
223
431
|
});
|
|
224
432
|
|
|
433
|
+
const gateCount = entries.filter((e) => e.gate).length;
|
|
225
434
|
const name =
|
|
226
|
-
|
|
227
|
-
? `sequence ${
|
|
228
|
-
: `sequence ${
|
|
435
|
+
entries.length === 1
|
|
436
|
+
? `sequence ${entries[0].issueKey}`
|
|
437
|
+
: `sequence ${entries.length} issues${gateCount > 0 ? ` with ${gateCount} gate${gateCount === 1 ? "" : "s"}` : ""}${behindKey ? ` behind ${behindKey}` : ""}`;
|
|
229
438
|
|
|
230
439
|
return { name, nodes, edges };
|
|
231
440
|
}
|
|
@@ -69,6 +69,49 @@ describe("S5 — the addressable operator guide over MCP (#611)", () => {
|
|
|
69
69
|
assert(res.text.length < 30000, "one section must fit a typical tool-result budget in a single call");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("section pagination: start/length pages a section and nextStart walks it to completion (#740)", async () => {
|
|
73
|
+
const whole = await h.callTool("getAgentGuide", { section: "delivery-graphs" });
|
|
74
|
+
const wholeSection = (whole.json as { section?: { instructions: string } }).section!;
|
|
75
|
+
const wholeChars = Array.from(wholeSection.instructions);
|
|
76
|
+
|
|
77
|
+
const PAGE = 4000;
|
|
78
|
+
let start = 0;
|
|
79
|
+
let assembled = "";
|
|
80
|
+
let pages = 0;
|
|
81
|
+
let total = -1;
|
|
82
|
+
for (;;) {
|
|
83
|
+
const res = await h.callTool("getAgentGuide", { section: "delivery-graphs", start, length: PAGE });
|
|
84
|
+
assert(!res.isError, `a paged fetch must not error: ${res.text}`);
|
|
85
|
+
const body = res.json as {
|
|
86
|
+
kind?: string;
|
|
87
|
+
section?: { id: string; instructions: string; start: number; length: number; totalLength: number; nextStart: number | null };
|
|
88
|
+
};
|
|
89
|
+
assert.equal(body.kind, "section");
|
|
90
|
+
const s = body.section!;
|
|
91
|
+
assert.equal(s.id, "delivery-graphs");
|
|
92
|
+
assert(Array.from(s.instructions).length <= PAGE, "a page must be bounded by `length`");
|
|
93
|
+
assert.equal(s.start, start);
|
|
94
|
+
if (total !== -1) assert.equal(s.totalLength, total, "totalLength is stable across pages");
|
|
95
|
+
total = s.totalLength;
|
|
96
|
+
assembled += s.instructions;
|
|
97
|
+
pages++;
|
|
98
|
+
if (s.nextStart === null) break;
|
|
99
|
+
start = s.nextStart;
|
|
100
|
+
assert(pages < 100, "pagination must terminate");
|
|
101
|
+
}
|
|
102
|
+
assert(pages > 1, "a large section must span multiple pages at this window");
|
|
103
|
+
assert.equal(total, wholeChars.length, "totalLength equals the full section length");
|
|
104
|
+
assert.equal(assembled, wholeSection.instructions, "the reassembled pages equal the whole section");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("section pagination: a non-integer/negative start is a uniform issues[{path,message}] 400 (#740)", async () => {
|
|
108
|
+
const res = await h.callTool("getAgentGuide", { section: "delivery-graphs", start: -5 });
|
|
109
|
+
assert(res.isError, "an invalid pagination arg must surface as a tool-level error");
|
|
110
|
+
const body = res.json as { issues?: { path: string; message: string }[] };
|
|
111
|
+
assert(Array.isArray(body.issues) && body.issues.length >= 1, "must answer with issues[]");
|
|
112
|
+
assert(body.issues!.some((i) => i.path.includes("start")), "the issue must name the `start` argument");
|
|
113
|
+
});
|
|
114
|
+
|
|
72
115
|
test("an unknown section id is rejected with issues[{path,message}]", async () => {
|
|
73
116
|
const res = await h.callTool("getAgentGuide", { section: "no-such-section" });
|
|
74
117
|
assert(res.isError, "an unknown section id must surface as a tool-level error");
|