@nanobpm/nano-workforce 0.161.0 → 0.162.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/deliveryGraphStage.ts +108 -0
- package/app/sequenceIssues.test.ts +186 -0
- package/app/sequenceIssues.ts +236 -0
- package/docs/agent-guide.md +11 -0
- package/e2e/sequenceIssues.e2e.ts +94 -0
- package/openapi.yaml +142 -0
- package/operations/compileDeliveryGraph.ts +21 -59
- package/operations/sequenceIssues.test.ts +101 -0
- package/operations/sequenceIssues.ts +55 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.162.0](https://github.com/nanobpm/nano-workforce/compare/v0.161.0...v0.162.0) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* add sequenceIssues intent door for canonical delivery-graph chains ([#618](https://github.com/nanobpm/nano-workforce/issues/618)) ([698f4bf](https://github.com/nanobpm/nano-workforce/commit/698f4bf5b7e873bd1805ea4c671162db7037de5a)), closes [#605](https://github.com/nanobpm/nano-workforce/issues/605) [#610](https://github.com/nanobpm/nano-workforce/issues/610)
|
|
6
|
+
|
|
1
7
|
## [0.161.0](https://github.com/nanobpm/nano-workforce/compare/v0.160.0...v0.161.0) (2026-08-29)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// nano-workforce — the ONE compile-and-stage flow shared by every agent-facing door that stages a
|
|
2
|
+
// delivery graph (epic nano-workforce#605). Extracted from `operations/compileDeliveryGraph.ts` (S0)
|
|
3
|
+
// so the intent-shaped generator doors (S4 — `sequenceIssues`) hand their CONSTRUCTED graph to the
|
|
4
|
+
// EXACT same deterministic compile → validate → stage path the raw `compileDeliveryGraph` door uses:
|
|
5
|
+
// one compiler (`compileDeliveryGraph`), one staging path (`stageProposal`), one idempotency/digest
|
|
6
|
+
// semantics (content-addressed by `deliveryGraphDigest`). Per AGENTS.md "Derivation over duplication:
|
|
7
|
+
// no drift surfaces", a generator MUST NOT re-implement a second runner or a second staging path —
|
|
8
|
+
// it only produces the `DeliveryGraph` and delegates here.
|
|
9
|
+
//
|
|
10
|
+
// The result mirrors the `compileDeliveryGraph` operation's wire contract exactly: a `200` carrying
|
|
11
|
+
// the `CompileDeliveryGraphStaged` preview + navigational `reviewUrl` (NO dispatch handle — dispatch
|
|
12
|
+
// stays an operator-only cockpit action, ADR 0005 Decision 7 / issue #460), or a `400` carrying the
|
|
13
|
+
// `CompileDeliveryGraphErrors` `{ ok:false, errors:[{path,message}] }` when the graph fails shape or
|
|
14
|
+
// semantic validation. Nothing is staged on a rejected compile.
|
|
15
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
16
|
+
import type { CompileDeliveryGraphErrors, CompileDeliveryGraphStaged } from "../nano-generated/api-io.d.ts";
|
|
17
|
+
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
18
|
+
import {
|
|
19
|
+
buildProposalPreview,
|
|
20
|
+
buildProposalRow,
|
|
21
|
+
proposalLogicalKey,
|
|
22
|
+
proposalReviewUrl,
|
|
23
|
+
stageProposal,
|
|
24
|
+
} from "./deliveryGraphProposals.ts";
|
|
25
|
+
import { deliveryGraphDigest } from "./deliveryRunner.ts";
|
|
26
|
+
|
|
27
|
+
/** The human-readable instruction every staged compile hands back — the agent's surface ends here;
|
|
28
|
+
* dispatch is an operator action and there is no start endpoint (capability-by-absence, #460). */
|
|
29
|
+
export const STAGED_MESSAGE =
|
|
30
|
+
"The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
|
|
31
|
+
|
|
32
|
+
/** A successful stage — the `CompileDeliveryGraphStaged` body plus the compiled `digest` and node
|
|
33
|
+
* counts, so a caller can log what it staged without re-deriving them. */
|
|
34
|
+
export interface StagedResult {
|
|
35
|
+
ok: true;
|
|
36
|
+
status: 200;
|
|
37
|
+
body: CompileDeliveryGraphStaged;
|
|
38
|
+
digest: string;
|
|
39
|
+
nodeCount: number;
|
|
40
|
+
humanNodeCount: number;
|
|
41
|
+
sideEffectCount: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A rejected compile — the `CompileDeliveryGraphErrors` body, verbatim from the compiler. */
|
|
45
|
+
export interface StageErrors {
|
|
46
|
+
ok: false;
|
|
47
|
+
status: 400;
|
|
48
|
+
body: CompileDeliveryGraphErrors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compile a `DeliveryGraph` and, when valid, persist it as a `staged` proposal — the single
|
|
53
|
+
* compile+stage flow (see the module header). `graph` is the structured `DeliveryGraph` object (an
|
|
54
|
+
* agent-authored one from `compileDeliveryGraph`, or a generator-CONSTRUCTED one from a `start/*`
|
|
55
|
+
* intent door); `graphJson` is the serialised form persisted on the proposal row for the cockpit
|
|
56
|
+
* dispatch to re-run (the caller supplies it so a generator can persist the SAME object it compiled,
|
|
57
|
+
* byte-for-byte). `origin` is the public origin the request arrived on, used to build the
|
|
58
|
+
* navigational `reviewUrl`. Never dispatches; never throws for a malformed graph (the compiler maps
|
|
59
|
+
* unknown input to a clean `ok:false`).
|
|
60
|
+
*/
|
|
61
|
+
export async function compileAndStageDeliveryGraph(
|
|
62
|
+
data: DataLayer,
|
|
63
|
+
graph: unknown,
|
|
64
|
+
graphJson: string,
|
|
65
|
+
origin: string,
|
|
66
|
+
): Promise<StagedResult | StageErrors> {
|
|
67
|
+
const result = await compileDeliveryGraph(graph);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
return { ok: false, status: 400, body: result };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const digest = deliveryGraphDigest(result.bpmn);
|
|
73
|
+
const name =
|
|
74
|
+
typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
|
|
75
|
+
? result.resolved.name.trim()
|
|
76
|
+
: null;
|
|
77
|
+
const preview = buildProposalPreview(result);
|
|
78
|
+
await stageProposal(
|
|
79
|
+
data,
|
|
80
|
+
buildProposalRow({
|
|
81
|
+
digest,
|
|
82
|
+
logicalKey: proposalLogicalKey(name, digest),
|
|
83
|
+
title: name,
|
|
84
|
+
graphJson,
|
|
85
|
+
preview,
|
|
86
|
+
nodeCount: result.resolved.nodes.length,
|
|
87
|
+
humanNodeCount: result.humanNodes.length,
|
|
88
|
+
sideEffectCount: result.sideEffects.length,
|
|
89
|
+
sideEffecting: result.sideEffects.length > 0,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
status: 200,
|
|
96
|
+
body: {
|
|
97
|
+
status: "ready",
|
|
98
|
+
message: STAGED_MESSAGE,
|
|
99
|
+
digest,
|
|
100
|
+
preview,
|
|
101
|
+
reviewUrl: proposalReviewUrl(digest, origin),
|
|
102
|
+
},
|
|
103
|
+
digest,
|
|
104
|
+
nodeCount: result.resolved.nodes.length,
|
|
105
|
+
humanNodeCount: result.humanNodes.length,
|
|
106
|
+
sideEffectCount: result.sideEffects.length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Tests for the `sequenceIssues` intent → canonical delivery-graph GENERATOR (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). The core acceptance guard: the generated graph is EQUIVALENT to the
|
|
3
|
+
// hand-authored canonical §9.4 chain for the same inputs — same node kinds, edges, and `pr`-fact
|
|
4
|
+
// threading — so an agent never re-authors 13 nodes + 12 edges by hand. Also pins the input/vocabulary
|
|
5
|
+
// validation contract (`issues[{path,message}]`).
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
|
|
9
|
+
import { validateDeliveryGraph } from "../app/deliveryGraph.ts";
|
|
10
|
+
import { buildSequenceGraph, MAX_SEQUENCE_ISSUES, MERGE_POLL } from "./sequenceIssues.ts";
|
|
11
|
+
|
|
12
|
+
type AnyGraph = { name?: string; nodes: any[]; edges: any[] };
|
|
13
|
+
|
|
14
|
+
function ok(intent: unknown): AnyGraph {
|
|
15
|
+
const res = buildSequenceGraph(intent);
|
|
16
|
+
assert(res.ok, `expected ok, got ${JSON.stringify(res)}`);
|
|
17
|
+
return res.graph as AnyGraph;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The hand-authored canonical chain for the SAME inputs — what §9.4 says an agent would build by
|
|
21
|
+
* hand. The generator must produce a structurally-equivalent graph. */
|
|
22
|
+
function handAuthored(behind: string | null, issues: string[]): AnyGraph {
|
|
23
|
+
const nodes: any[] = [];
|
|
24
|
+
const edges: any[] = [];
|
|
25
|
+
if (behind) {
|
|
26
|
+
nodes.push({
|
|
27
|
+
id: "gate-epic",
|
|
28
|
+
kind: "wait",
|
|
29
|
+
wait: { kind: "epic", target: behind, match: { epicState: "merged" }, poll: { ...MERGE_POLL }, onTimeout: "escalate" },
|
|
30
|
+
emits: [{ name: "prCount", type: "number" }],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
issues.forEach((issue, i) => {
|
|
34
|
+
const n = i + 1;
|
|
35
|
+
nodes.push({
|
|
36
|
+
id: `open-${n}`,
|
|
37
|
+
kind: "agent",
|
|
38
|
+
agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.` },
|
|
39
|
+
emits: [{ name: "pr", type: "pr" }],
|
|
40
|
+
});
|
|
41
|
+
nodes.push({ id: `land-${n}`, kind: "connector", connector: { target: "converge-merge", payload: { pr: `open-${n}.pr` } } });
|
|
42
|
+
nodes.push({
|
|
43
|
+
id: `merged-${n}`,
|
|
44
|
+
kind: "wait",
|
|
45
|
+
wait: { kind: "pr", target: `open-${n}.pr`, match: { prState: "merged" }, poll: { ...MERGE_POLL }, onTimeout: "escalate" },
|
|
46
|
+
});
|
|
47
|
+
edges.push({ from: `open-${n}.pr`, to: `land-${n}` });
|
|
48
|
+
edges.push({ from: `open-${n}.pr`, to: `merged-${n}` });
|
|
49
|
+
if (i === 0) {
|
|
50
|
+
if (behind) edges.push({ from: "gate-epic", to: `open-${n}` });
|
|
51
|
+
} else {
|
|
52
|
+
edges.push({ from: `merged-${i}`, to: `open-${n}` });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return { nodes, edges };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Compare two graphs by their SEMANTIC content — node set (by id) and edge set — order-insensitive. */
|
|
59
|
+
function assertEquivalent(actual: AnyGraph, expected: AnyGraph): void {
|
|
60
|
+
const byId = (g: AnyGraph) => new Map(g.nodes.map((n) => [n.id, n]));
|
|
61
|
+
const a = byId(actual);
|
|
62
|
+
const e = byId(expected);
|
|
63
|
+
assertEquals([...a.keys()].sort(), [...e.keys()].sort(), "same node ids");
|
|
64
|
+
for (const [id, node] of e) assertEquals(a.get(id), node, `node ${id} matches canonical`);
|
|
65
|
+
const edgeKey = (x: any) => JSON.stringify([x.from, x.to, x.when ?? null, x.equals ?? null, x.default ?? null]);
|
|
66
|
+
assertEquals(actual.edges.map(edgeKey).sort(), expected.edges.map(edgeKey).sort(), "same edge set");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("sequenceIssues: four issues behind a gate → the exact 13-node / 12-edge canonical chain", () => {
|
|
70
|
+
const behind = "acme/repo#100";
|
|
71
|
+
const issues = ["acme/repo#1", "acme/repo#2", "acme/repo#3", "acme/repo#4"];
|
|
72
|
+
const graph = ok({ behind, issues });
|
|
73
|
+
// The evidence-session shape: 1 gate + 3 nodes/issue = 13 nodes; 8 fact edges + 1 gate edge + 3
|
|
74
|
+
// sequence edges = 12 edges.
|
|
75
|
+
assertEquals(graph.nodes.length, 13);
|
|
76
|
+
assertEquals(graph.edges.length, 12);
|
|
77
|
+
assertEquivalent(graph, handAuthored(behind, issues));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("sequenceIssues: without `behind`, no leading epic gate and no gate edge", () => {
|
|
81
|
+
const issues = ["acme/repo#1", "acme/repo#2"];
|
|
82
|
+
const graph = ok({ issues });
|
|
83
|
+
assertEquals(graph.nodes.length, 6);
|
|
84
|
+
assertEquals(graph.edges.length, 5); // 2 fact edges/issue (4) + 1 sequence edge
|
|
85
|
+
assert(!graph.nodes.some((n) => n.id === "gate-epic"), "no epic gate without `behind`");
|
|
86
|
+
assertEquivalent(graph, handAuthored(null, issues));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("sequenceIssues: each issue emits agent(senior:feature,emits pr) → connector(converge-merge) → wait[pr,merged]", () => {
|
|
90
|
+
const graph = ok({ issues: ["acme/repo#7"] });
|
|
91
|
+
const open = graph.nodes.find((n) => n.id === "open-1");
|
|
92
|
+
const land = graph.nodes.find((n) => n.id === "land-1");
|
|
93
|
+
const merged = graph.nodes.find((n) => n.id === "merged-1");
|
|
94
|
+
assertEquals(open.kind, "agent");
|
|
95
|
+
assertEquals(open.agent.jobType, "senior:feature");
|
|
96
|
+
assertEquals(open.emits, [{ name: "pr", type: "pr" }]);
|
|
97
|
+
assertEquals(land.kind, "connector");
|
|
98
|
+
assertEquals(land.connector.target, "converge-merge");
|
|
99
|
+
assertEquals(land.connector.payload, { pr: "open-1.pr" });
|
|
100
|
+
assertEquals(merged.kind, "wait");
|
|
101
|
+
assertEquals(merged.wait.kind, "pr");
|
|
102
|
+
assertEquals(merged.wait.target, "open-1.pr");
|
|
103
|
+
assertEquals(merged.wait.match, { prState: "merged" });
|
|
104
|
+
// The pr fact is threaded to BOTH consumers by fact-qualified edges (§9.4).
|
|
105
|
+
assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "land-1"));
|
|
106
|
+
assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "merged-1"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("sequenceIssues: merge/epic gates carry a realistic poll budget (not the 30-min default trap)", () => {
|
|
110
|
+
const graph = ok({ behind: "acme/repo#9", issues: ["acme/repo#1"] });
|
|
111
|
+
const gate = graph.nodes.find((n) => n.id === "gate-epic");
|
|
112
|
+
const merged = graph.nodes.find((n) => n.id === "merged-1");
|
|
113
|
+
assertEquals(gate.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
|
|
114
|
+
assertEquals(merged.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
|
|
115
|
+
assert(merged.wait.poll.timeoutMs > 30 * 60 * 1000, "budget must exceed the 30-minute default");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("sequenceIssues: the generated graph passes validateDeliveryGraph AND compiles", async () => {
|
|
119
|
+
const graph = ok({ behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2", "acme/repo#3", "acme/repo#4"] });
|
|
120
|
+
assertEquals(validateDeliveryGraph(graph), []);
|
|
121
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
122
|
+
assert(compiled.ok, `expected the generated graph to compile, got ${JSON.stringify(compiled)}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("sequenceIssues: an issue URL is accepted and normalised to owner/repo#N", () => {
|
|
126
|
+
const graph = ok({ issues: ["https://github.com/acme/repo/issues/42"] });
|
|
127
|
+
const open = graph.nodes.find((n) => n.id === "open-1");
|
|
128
|
+
assertEquals(open.agent.prompt, "Implement acme/repo#42 and open a PR.");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── Validation: the uniform issues[{path,message}] contract ─────────────────────────────────────
|
|
132
|
+
function rejects(intent: unknown): Array<{ path: string; message: string }> {
|
|
133
|
+
const res = buildSequenceGraph(intent);
|
|
134
|
+
assert(!res.ok, `expected rejection, got ${JSON.stringify(res)}`);
|
|
135
|
+
assert(Array.isArray(res.issues) && res.issues.length > 0, "issues[] must be non-empty");
|
|
136
|
+
for (const iss of res.issues) {
|
|
137
|
+
assert(typeof iss.path === "string" && typeof iss.message === "string", `bad issue ${JSON.stringify(iss)}`);
|
|
138
|
+
}
|
|
139
|
+
return res.issues;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test("sequenceIssues: empty issues → rejected with issues[{path,message}]", () => {
|
|
143
|
+
const issues = rejects({ issues: [] });
|
|
144
|
+
assert(issues.some((i) => i.path === "issues"));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("sequenceIssues: missing issues → rejected", () => {
|
|
148
|
+
const issues = rejects({});
|
|
149
|
+
assert(issues.some((i) => i.path === "issues"));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("sequenceIssues: an unparseable issue ref → rejected at the offending index", () => {
|
|
153
|
+
const issues = rejects({ issues: ["acme/repo#1", "not-an-issue"] });
|
|
154
|
+
assert(issues.some((i) => i.path === "issues[1]"), `expected issues[1] path, got ${JSON.stringify(issues)}`);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("sequenceIssues: an unparseable `behind` ref → rejected at behind", () => {
|
|
158
|
+
const issues = rejects({ behind: "nope", issues: ["acme/repo#1"] });
|
|
159
|
+
assert(issues.some((i) => i.path === "behind"));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("sequenceIssues: an empty-string `behind` → rejected at behind (not silently ungated)", () => {
|
|
163
|
+
const issues = rejects({ behind: "", issues: ["acme/repo#1"] });
|
|
164
|
+
assert(issues.some((i) => i.path === "behind"), `expected behind path, got ${JSON.stringify(issues)}`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("sequenceIssues: a non-positive issue number (`#0`) → rejected at the offending index", () => {
|
|
168
|
+
const issues = rejects({ issues: ["acme/repo#0"] });
|
|
169
|
+
assert(issues.some((i) => i.path === "issues[0]"), `expected issues[0] path, got ${JSON.stringify(issues)}`);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("sequenceIssues: an unsafe-integer issue number → rejected at the offending index", () => {
|
|
173
|
+
const issues = rejects({ issues: ["acme/repo#1", "acme/repo#99999999999999999999"] });
|
|
174
|
+
assert(issues.some((i) => i.path === "issues[1]"), `expected issues[1] path, got ${JSON.stringify(issues)}`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("sequenceIssues: a non-positive `behind` number (`#0`) → rejected at behind", () => {
|
|
178
|
+
const issues = rejects({ behind: "acme/repo#0", issues: ["acme/repo#1"] });
|
|
179
|
+
assert(issues.some((i) => i.path === "behind"), `expected behind path, got ${JSON.stringify(issues)}`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("sequenceIssues: more than the max issues → rejected", () => {
|
|
183
|
+
const many = Array.from({ length: MAX_SEQUENCE_ISSUES + 1 }, (_, i) => `acme/repo#${i + 1}`);
|
|
184
|
+
const issues = rejects({ issues: many });
|
|
185
|
+
assert(issues.some((i) => i.path === "issues"));
|
|
186
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// nano-workforce — the `sequenceIssues` intent → canonical delivery-graph GENERATOR (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). ADR 0005's delivery graph is a closed vocabulary, and §9.4 of the
|
|
3
|
+
// operator guide already names the canonical shape for "implement issue → converge → merge". But an
|
|
4
|
+
// agent still had to hand-author the full node/edge JSON: in the evidence session, sequencing four
|
|
5
|
+
// issues behind a gate meant constructing 13 nodes and 12 edges by hand. This module produces that
|
|
6
|
+
// exact shape from a high-level INTENT instead.
|
|
7
|
+
//
|
|
8
|
+
// The intent is `{ behind?: "owner/repo#NN", issues: ["owner/repo#A", …] }`. For each issue it emits
|
|
9
|
+
// the canonical chain — `agent` (`senior:feature`, emits a `pr` fact) → `connector` (`converge-merge`,
|
|
10
|
+
// late-binding that `pr`) → `wait[pr, merged]` (a realistic `poll.timeoutMs`) — and threads the `pr`
|
|
11
|
+
// fact along fact-qualified edges per §9.4. The issues run in SEQUENCE: each issue's agent starts once
|
|
12
|
+
// the PRIOR issue has merged. An optional leading `wait[epic]` gate (when `behind` is given) makes the
|
|
13
|
+
// whole sequence wait for that issue/epic/feature to be fully merged first (§9.5).
|
|
14
|
+
//
|
|
15
|
+
// It is a PURE builder: no I/O, no staging. The operation (`operations/sequenceIssues.ts`) hands the
|
|
16
|
+
// constructed `DeliveryGraph` to the SAME `compileAndStageDeliveryGraph` flow the raw
|
|
17
|
+
// `compileDeliveryGraph` door uses (one compiler, one staging path — AGENTS.md "no drift surfaces").
|
|
18
|
+
// The generated graph is authored to pass `validateDeliveryGraph` by construction: unique node ids,
|
|
19
|
+
// `pr`-typed emits, threaded fact edges, a DAG. It is validated against the S3 vocabulary
|
|
20
|
+
// (`deliveryGraphVocabulary`) so an unknown connector target / probe kind is rejected at the door with
|
|
21
|
+
// `issues[{path,message}]` rather than only surfacing at compile time.
|
|
22
|
+
import type { DeliveryEdge, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
23
|
+
import { CONVERGE_MERGE_TARGET } from "./convergeTargets.ts";
|
|
24
|
+
import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
|
|
25
|
+
import { type ParsedIssue, parseIssue } from "./plan.ts";
|
|
26
|
+
|
|
27
|
+
/** A path-qualified validation failure — the uniform door error contract `issues[{path,message}]`
|
|
28
|
+
* (the same shape the runtime request-validator and the S1 harness assert). */
|
|
29
|
+
export interface SequenceIssueError {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `sequenceIssues` intent body — an optional leading `behind` gate plus the ordered `issues`. */
|
|
35
|
+
export interface SequenceIssuesIntent {
|
|
36
|
+
behind?: string;
|
|
37
|
+
issues: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The result of {@link buildSequenceGraph}: either the constructed graph, or the path-qualified
|
|
41
|
+
* input/vocabulary rejections. */
|
|
42
|
+
export type SequenceIssuesResult =
|
|
43
|
+
| { ok: true; graph: DeliveryGraph }
|
|
44
|
+
| { ok: false; issues: SequenceIssueError[] };
|
|
45
|
+
|
|
46
|
+
/** The realistic merge-gate poll budget the canonical shape uses (§9.4 / §9.1): re-probe every 5
|
|
47
|
+
* minutes, budget 3 days. A `wait[pr, merged]` / `wait[epic]` waits on a human-paced merge, so the
|
|
48
|
+
* 30-minute default poll budget is a trap — always set an explicit `poll.timeoutMs` on a merge/epic
|
|
49
|
+
* gate. Exported so the regression test asserts the generated gate against the canonical values. */
|
|
50
|
+
export const MERGE_POLL = { everyMs: 300_000, timeoutMs: 259_200_000 } as const;
|
|
51
|
+
|
|
52
|
+
/** The wait-probe kind that observes a single in-flight PR's merge state (ADR 0005 §2). */
|
|
53
|
+
const PR_PROBE_KIND = "pr";
|
|
54
|
+
/** The wait-probe kind that observes a whole epic/feature lineage reaching "fully merged" (§9.5). */
|
|
55
|
+
const EPIC_PROBE_KIND = "epic";
|
|
56
|
+
/** The agent job type each issue node runs (a full single-issue feature implementation). */
|
|
57
|
+
const AGENT_JOB_TYPE = "senior:feature";
|
|
58
|
+
/** The upper bound on issues in one sequence — keeps the generated graph within the compiler's
|
|
59
|
+
* 256-node ceiling (3 nodes/issue + an optional gate) with generous headroom, and bounds the intent. */
|
|
60
|
+
export const MAX_SEQUENCE_ISSUES = 64;
|
|
61
|
+
|
|
62
|
+
/** A `pr`-typed emit declaration — what an `agent` node publishes for the PR it opened, so the
|
|
63
|
+
* downstream connector / `wait[pr]` node late-binds its target PR from the fact (§9.4, issue #548). */
|
|
64
|
+
const PR_EMIT = { name: "pr", type: "pr" as const };
|
|
65
|
+
|
|
66
|
+
/** Parse a ref into an issue target ONLY if its number is a positive, safe integer. `parseIssue`'s
|
|
67
|
+
* `\d+` accepts `#0` and precision-overflowing numbers (e.g. `#99999999999999999999`, which coerces
|
|
68
|
+
* past `Number.MAX_SAFE_INTEGER`), but such a target can never resolve to a real issue/PR — staging a
|
|
69
|
+
* gate on it would wait forever. Reject it deterministically at the door instead (Copilot review,
|
|
70
|
+
* PR #618), so only `#N` with `N >= 1 && Number.isSafeInteger(N)` is accepted. */
|
|
71
|
+
function parseIssueRef(ref: unknown): ParsedIssue | null {
|
|
72
|
+
const parsed = typeof ref === "string" ? parseIssue(ref) : null;
|
|
73
|
+
if (!parsed) return null;
|
|
74
|
+
return Number.isSafeInteger(parsed.number) && parsed.number >= 1 ? parsed : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the canonical delivery graph for a `sequenceIssues` intent, or return path-qualified
|
|
79
|
+
* `issues[{path,message}]` rejections for invalid input. Validates:
|
|
80
|
+
* - `issues` is a non-empty array within {@link MAX_SEQUENCE_ISSUES};
|
|
81
|
+
* - every `issues[i]` and the optional `behind` parse as an `owner/repo#N` reference;
|
|
82
|
+
* - the connector target and wait-probe kinds it emits are known to the S3 vocabulary (drift guard).
|
|
83
|
+
* Pure — no I/O. The constructed graph passes `validateDeliveryGraph` by construction.
|
|
84
|
+
*/
|
|
85
|
+
export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
86
|
+
const issues: SequenceIssueError[] = [];
|
|
87
|
+
|
|
88
|
+
const body = isRecord(intent) ? intent : {};
|
|
89
|
+
const rawIssues = body.issues;
|
|
90
|
+
const rawBehind = body.behind;
|
|
91
|
+
|
|
92
|
+
// ── `issues`: a non-empty, bounded array of parseable refs ───────────────────────────────────
|
|
93
|
+
const parsedIssues: string[] = [];
|
|
94
|
+
if (!Array.isArray(rawIssues)) {
|
|
95
|
+
issues.push({ path: "issues", message: "`issues` must be a non-empty array of `owner/repo#N` issue references." });
|
|
96
|
+
} else if (rawIssues.length === 0) {
|
|
97
|
+
issues.push({ path: "issues", message: "`issues` must contain at least one `owner/repo#N` issue reference." });
|
|
98
|
+
} else if (rawIssues.length > MAX_SEQUENCE_ISSUES) {
|
|
99
|
+
issues.push({
|
|
100
|
+
path: "issues",
|
|
101
|
+
message: `\`issues\` has too many entries (${rawIssues.length}) — the limit is ${MAX_SEQUENCE_ISSUES}.`,
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
rawIssues.forEach((ref, i) => {
|
|
105
|
+
const parsed = parseIssueRef(ref);
|
|
106
|
+
if (!parsed) {
|
|
107
|
+
issues.push({
|
|
108
|
+
path: `issues[${i}]`,
|
|
109
|
+
message: `\`${String(ref)}\` is not a valid \`owner/repo#N\` issue reference.`,
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
parsedIssues.push(parsed.planKey);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── `behind` (optional): a parseable ref, when PRESENT ───────────────────────────────────────
|
|
118
|
+
// Only an OMITTED gate (`undefined`/`null`) is "no gate"; a present-but-empty `behind: ""` is a
|
|
119
|
+
// caller mistake (the schema requires `minLength: 1`), not an ungated sequence — reject it rather
|
|
120
|
+
// than silently dropping the gate the caller asked for (matters most when this builder is invoked
|
|
121
|
+
// directly, bypassing OpenAPI validation).
|
|
122
|
+
let behindKey: string | null = null;
|
|
123
|
+
if (rawBehind !== undefined && rawBehind !== null) {
|
|
124
|
+
const parsed = parseIssueRef(rawBehind);
|
|
125
|
+
if (!parsed) {
|
|
126
|
+
issues.push({
|
|
127
|
+
path: "behind",
|
|
128
|
+
message: `\`${String(rawBehind)}\` is not a valid \`owner/repo#N\` issue/epic reference.`,
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
behindKey = parsed.planKey;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Vocabulary drift guard (S3): the target/probe kinds this generator emits MUST be known to the
|
|
136
|
+
// structured vocabulary. This can only trip if the closed vocabulary changes underneath us — it is
|
|
137
|
+
// surfaced as a door `issue` (not a throw) so the failure mode is a clean rejection, not a 500. ──
|
|
138
|
+
const vocab = deliveryGraphVocabulary();
|
|
139
|
+
const realTargets = new Set(vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target));
|
|
140
|
+
if (!realTargets.has(CONVERGE_MERGE_TARGET)) {
|
|
141
|
+
issues.push({
|
|
142
|
+
path: "issues",
|
|
143
|
+
message: `connector target \`${CONVERGE_MERGE_TARGET}\` is not a real target in the delivery-graph vocabulary.`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
|
|
147
|
+
for (const kind of behindKey ? [PR_PROBE_KIND, EPIC_PROBE_KIND] : [PR_PROBE_KIND]) {
|
|
148
|
+
if (!probeKinds.has(kind)) {
|
|
149
|
+
issues.push({ path: "issues", message: `wait-probe kind \`${kind}\` is not in the delivery-graph vocabulary.` });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
154
|
+
|
|
155
|
+
return { ok: true, graph: assembleGraph(parsedIssues, behindKey) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Assemble the canonical node/edge chain for the (already-validated) issue keys + optional gate. */
|
|
159
|
+
function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryGraph {
|
|
160
|
+
const nodes: DeliveryNode[] = [];
|
|
161
|
+
const edges: DeliveryEdge[] = [];
|
|
162
|
+
|
|
163
|
+
// Optional leading `wait[epic]` gate — the whole sequence waits for `behind` to be fully merged.
|
|
164
|
+
const GATE_ID = "gate-epic";
|
|
165
|
+
if (behindKey) {
|
|
166
|
+
nodes.push({
|
|
167
|
+
id: GATE_ID,
|
|
168
|
+
kind: "wait",
|
|
169
|
+
wait: {
|
|
170
|
+
kind: EPIC_PROBE_KIND,
|
|
171
|
+
target: behindKey,
|
|
172
|
+
match: { epicState: "merged" },
|
|
173
|
+
poll: { ...MERGE_POLL },
|
|
174
|
+
onTimeout: "escalate",
|
|
175
|
+
},
|
|
176
|
+
emits: [{ name: "prCount", type: "number" }],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
issueKeys.forEach((issueKey, i) => {
|
|
181
|
+
const n = i + 1;
|
|
182
|
+
const openId = `open-${n}`;
|
|
183
|
+
const landId = `land-${n}`;
|
|
184
|
+
const mergedId = `merged-${n}`;
|
|
185
|
+
const prRef = `${openId}.pr`;
|
|
186
|
+
|
|
187
|
+
// agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind.
|
|
188
|
+
nodes.push({
|
|
189
|
+
id: openId,
|
|
190
|
+
kind: "agent",
|
|
191
|
+
agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.` },
|
|
192
|
+
emits: [{ ...PR_EMIT }],
|
|
193
|
+
});
|
|
194
|
+
// connector[converge-merge] → drive the opened PR through review convergence + the merge loop.
|
|
195
|
+
nodes.push({
|
|
196
|
+
id: landId,
|
|
197
|
+
kind: "connector",
|
|
198
|
+
connector: { target: CONVERGE_MERGE_TARGET, payload: { pr: prRef } },
|
|
199
|
+
});
|
|
200
|
+
// wait[pr, merged] → observe the PR reaching `merged`, with a realistic poll budget.
|
|
201
|
+
nodes.push({
|
|
202
|
+
id: mergedId,
|
|
203
|
+
kind: "wait",
|
|
204
|
+
wait: {
|
|
205
|
+
kind: PR_PROBE_KIND,
|
|
206
|
+
target: prRef,
|
|
207
|
+
match: { prState: "merged" },
|
|
208
|
+
poll: { ...MERGE_POLL },
|
|
209
|
+
onTimeout: "escalate",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Thread the `pr` fact to both consumers (required — an unthreaded reference is `unbound-pr`).
|
|
214
|
+
edges.push({ from: prRef, to: landId });
|
|
215
|
+
edges.push({ from: prRef, to: mergedId });
|
|
216
|
+
|
|
217
|
+
// Sequence: this issue's agent starts once the PRIOR issue merged; the first waits on the gate.
|
|
218
|
+
if (i === 0) {
|
|
219
|
+
if (behindKey) edges.push({ from: GATE_ID, to: openId });
|
|
220
|
+
} else {
|
|
221
|
+
edges.push({ from: `merged-${i}`, to: openId });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const name =
|
|
226
|
+
issueKeys.length === 1
|
|
227
|
+
? `sequence ${issueKeys[0]}`
|
|
228
|
+
: `sequence ${issueKeys.length} issues${behindKey ? ` behind ${behindKey}` : ""}`;
|
|
229
|
+
|
|
230
|
+
return { name, nodes, edges };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Narrow an untyped value to a plain object so its fields can be read as `unknown`. */
|
|
234
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
235
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
236
|
+
}
|
package/docs/agent-guide.md
CHANGED
|
@@ -706,6 +706,17 @@ ways to name the target PR:
|
|
|
706
706
|
`senior:feature` already returns the PR it opened, so declaring `emits: [{ "name": "pr", "type": "pr" }]`
|
|
707
707
|
on the agent node is all it takes to publish it (issue #548).
|
|
708
708
|
|
|
709
|
+
> **Don't hand-author this shape — generate it.** When your intent is simply "sequence these
|
|
710
|
+
> issues, each implemented → converged → merged (optionally behind a gate)", call the
|
|
711
|
+
> **`sequenceIssues`** door instead of assembling the nodes/edges by hand. Its body is the intent
|
|
712
|
+
> `{ "issues": ["owner/repo#A", "owner/repo#B", …] }` with an optional leading `"behind": "owner/repo#NN"`
|
|
713
|
+
> gate, and it GENERATES
|
|
714
|
+
> exactly the canonical chain above — for each issue `agent` (`senior:feature`, emits `pr`) →
|
|
715
|
+
> `connector[converge-merge]` → `wait[pr, merged]` with a realistic `poll.timeoutMs`, threading the
|
|
716
|
+
> `pr` fact, plus an optional leading `wait[epic]` gate (§9.5) when `behind` is given — then STAGES it
|
|
717
|
+
> through the same compile+stage path as `compileDeliveryGraph` (it never dispatches). The issues run
|
|
718
|
+
> in **sequence**: each issue's implementation starts once the prior issue has merged.
|
|
719
|
+
|
|
709
720
|
### 9.5 Gate a graph on an epic reaching "fully merged" (`wait[epic]`)
|
|
710
721
|
|
|
711
722
|
Sometimes the thing you must wait for is not one PR but a **whole epic** — an nwf
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// `sequenceIssues` intent-door regression net (epic #605 slice S4, issue #610).
|
|
2
|
+
//
|
|
3
|
+
// PINS the acceptance guarantees over the REAL runtime-served `/app/mcp` surface:
|
|
4
|
+
// • the door is PROJECTED as an MCP tool whose input schema is self-contained ($ref-free, explicit
|
|
5
|
+
// type) — an agent can discover + call it from a standard client (S0 invariant);
|
|
6
|
+
// • an object-body intent arrives AS AN OBJECT (not stringified), stages a delivery graph, and the
|
|
7
|
+
// staged digest is immediately visible via `listStagedProposals` (compile+stage reuse, S2);
|
|
8
|
+
// • the response carries NO dispatch handle — the door STAGES, never dispatches (operator-only);
|
|
9
|
+
// • invalid input (empty `issues`, an unparseable ref) is rejected with `issues[{path,message}]`.
|
|
10
|
+
//
|
|
11
|
+
// It is RUNNABLE VIA THE SLICE S1 HARNESS (`e2e/support/mcp-harness.ts`): it imports `bootMcpHarness`
|
|
12
|
+
// and the shared assertion helpers and drives the exact client handshake an agent uses — it does NOT
|
|
13
|
+
// re-implement the transport (see the harness module header's EXTENSION SEAM).
|
|
14
|
+
//
|
|
15
|
+
// Run with `npm run e2e`.
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { after, before, describe, test } from "node:test";
|
|
18
|
+
import {
|
|
19
|
+
assertObjectBodyAccepted,
|
|
20
|
+
assertSchemaSelfContained,
|
|
21
|
+
assertValidationIssues,
|
|
22
|
+
bootMcpHarness,
|
|
23
|
+
type McpHarness,
|
|
24
|
+
} from "./support/mcp-harness.ts";
|
|
25
|
+
|
|
26
|
+
const TOOL = "sequenceIssues";
|
|
27
|
+
|
|
28
|
+
interface ListBody { count: number; proposals: Array<{ digest: string; title: string | null }> }
|
|
29
|
+
|
|
30
|
+
/** The current live staged list, read over the SAME MCP surface. */
|
|
31
|
+
async function listStaged(h: McpHarness): Promise<ListBody> {
|
|
32
|
+
const res = await h.callTool("listStagedProposals", {});
|
|
33
|
+
assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
|
|
34
|
+
const json = res.json as ListBody | undefined;
|
|
35
|
+
assert.ok(json && Array.isArray(json.proposals), `listStagedProposals must return a proposals array: ${res.text}`);
|
|
36
|
+
return json;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("S4 — sequenceIssues generates + stages the canonical chain over MCP (#610)", () => {
|
|
40
|
+
let h: McpHarness;
|
|
41
|
+
before(async () => { h = await bootMcpHarness(); });
|
|
42
|
+
after(async () => { await h.stop(); });
|
|
43
|
+
|
|
44
|
+
test("the tool is projected with a self-contained ($ref-free, typed) input schema", async () => {
|
|
45
|
+
const tools = await h.listTools();
|
|
46
|
+
const tool = tools.find((t) => t.name === TOOL);
|
|
47
|
+
assert.ok(tool, `${TOOL} must be projected onto the MCP surface`);
|
|
48
|
+
assertSchemaSelfContained(tool.inputSchema, TOOL);
|
|
49
|
+
// Dispatch is operator-only — the dispatch door must NOT be projected.
|
|
50
|
+
assert.ok(!tools.some((t) => t.name === "dispatchDeliveryGraph"), "dispatch stays off the agent surface");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a valid intent object stages the canonical graph — the staged digest is immediately listed", async () => {
|
|
54
|
+
const res = await h.callTool(TOOL, {
|
|
55
|
+
body: { behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2", "acme/repo#3"] },
|
|
56
|
+
});
|
|
57
|
+
assertObjectBodyAccepted(res, TOOL); // the object argument arrived as an object, not a string
|
|
58
|
+
assert.ok(!res.isError, `${TOOL} must stage a valid intent: ${res.text}`);
|
|
59
|
+
const json = res.json as { status?: string; digest?: string; preview?: unknown } | undefined;
|
|
60
|
+
assert.equal(json?.status, "ready", `${TOOL} must report status:"ready": ${res.text}`);
|
|
61
|
+
assert.ok(typeof json?.digest === "string" && json.digest.length > 0, `a staged digest is required: ${res.text}`);
|
|
62
|
+
assert.ok(json?.preview && typeof json.preview === "object", `a preview is required: ${res.text}`);
|
|
63
|
+
|
|
64
|
+
// Read-after-write: the staged digest is visible immediately (shared compile+stage path, S2).
|
|
65
|
+
const list = await listStaged(h);
|
|
66
|
+
assert.ok(
|
|
67
|
+
list.proposals.some((p) => p.digest === json.digest),
|
|
68
|
+
`the staged digest ${json.digest} must appear in listStagedProposals immediately (got ${JSON.stringify(list.proposals.map((p) => p.digest))})`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// The door STAGES, never dispatches — no run handle in the response.
|
|
72
|
+
for (const forbidden of ["runKey", "token", "approvalToken", "processInstanceKey", "processKey", "dispatchUrl"]) {
|
|
73
|
+
assert.ok(!(forbidden in (json as Record<string, unknown>)), `response must not carry a dispatch handle (${forbidden})`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("empty issues is rejected with the uniform issues[{path,message}] contract — nothing staged", async () => {
|
|
78
|
+
const before = (await listStaged(h)).proposals.length;
|
|
79
|
+
const res = await h.callTool(TOOL, { body: { issues: [] } });
|
|
80
|
+
// A tool-level door 4xx: the object arrived (not stringified) AND carries issues[{path,message}].
|
|
81
|
+
assertValidationIssues(res, TOOL);
|
|
82
|
+
assert.equal((await listStaged(h)).proposals.length, before, "a rejected intent must stage nothing");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an unparseable issue ref is rejected at the offending path", async () => {
|
|
86
|
+
const res = await h.callTool(TOOL, { body: { issues: ["acme/repo#1", "not-an-issue"] } });
|
|
87
|
+
assertValidationIssues(res, TOOL);
|
|
88
|
+
const json = res.json as { issues?: Array<{ path?: string }> };
|
|
89
|
+
assert.ok(
|
|
90
|
+
json.issues?.some((i) => i.path === "issues[1]"),
|
|
91
|
+
`the offending index must be path-qualified (got ${JSON.stringify(json.issues)})`,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/openapi.yaml
CHANGED
|
@@ -2460,6 +2460,71 @@ components:
|
|
|
2460
2460
|
description: >-
|
|
2461
2461
|
A NAVIGATIONAL cockpit deep-link to the staged proposal (helps the agent hand the human a
|
|
2462
2462
|
link). It is a pointer only — NOT a dispatch handle; nothing in this response can start a run.
|
|
2463
|
+
SequenceIssuesIntent:
|
|
2464
|
+
description: >-
|
|
2465
|
+
The `sequenceIssues` INTENT (epic nano-workforce#605, S4) — a high-level shape that GENERATES
|
|
2466
|
+
the canonical "implement issue → converge → merge" delivery graph (operator-guide §9.4) instead
|
|
2467
|
+
of making an agent hand-author its node/edge JSON. It names an ordered list of `issues` to
|
|
2468
|
+
sequence (each issue's implementation starts once the PRIOR issue has merged) and an OPTIONAL
|
|
2469
|
+
leading `behind` gate (wait for that issue/epic/feature to be fully merged first, §9.5). The
|
|
2470
|
+
door GENERATES, then STAGES the graph through the same compile+stage flow as
|
|
2471
|
+
`compileDeliveryGraph` — it never dispatches (dispatch is an operator-only cockpit action, ADR
|
|
2472
|
+
0005 Decision 7). For each issue it emits `agent` (`senior:feature`, emits a `pr` fact) →
|
|
2473
|
+
`connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic
|
|
2474
|
+
`poll.timeoutMs`), threading the `pr` fact per §9.4. Invalid input (empty `issues`, an
|
|
2475
|
+
unparseable ref) is a 400 carrying `issues: [{ path, message }]`; nothing is staged.
|
|
2476
|
+
type: object
|
|
2477
|
+
additionalProperties: false
|
|
2478
|
+
example:
|
|
2479
|
+
behind: nanobpm/nano-ide#488
|
|
2480
|
+
issues:
|
|
2481
|
+
- nanobpm/nano-workforce#567
|
|
2482
|
+
- nanobpm/nano-workforce#568
|
|
2483
|
+
required:
|
|
2484
|
+
- issues
|
|
2485
|
+
properties:
|
|
2486
|
+
behind:
|
|
2487
|
+
type: string
|
|
2488
|
+
minLength: 1
|
|
2489
|
+
maxLength: 255
|
|
2490
|
+
description: >-
|
|
2491
|
+
OPTIONAL gate — an `owner/repo#NN` issue/epic/feature reference. When present, a leading
|
|
2492
|
+
`wait[epic]` node gates the whole sequence on that reference reaching "fully merged" (every
|
|
2493
|
+
opened slice/PR landed, §9.5) before the first issue's implementation starts.
|
|
2494
|
+
issues:
|
|
2495
|
+
type: array
|
|
2496
|
+
minItems: 1
|
|
2497
|
+
maxItems: 64
|
|
2498
|
+
items:
|
|
2499
|
+
type: string
|
|
2500
|
+
minLength: 1
|
|
2501
|
+
maxLength: 255
|
|
2502
|
+
description: An `owner/repo#N` issue reference to implement + converge + merge, in sequence.
|
|
2503
|
+
description: >-
|
|
2504
|
+
The ordered issues to sequence — each is implemented by a `senior:feature` agent that opens
|
|
2505
|
+
a PR, driven to convergence + merge, and the NEXT issue's implementation starts only once
|
|
2506
|
+
the prior issue has merged. At least one; at most 64 (keeps the generated graph within the
|
|
2507
|
+
compiler's node ceiling).
|
|
2508
|
+
SequenceIssuesRejected:
|
|
2509
|
+
description: >-
|
|
2510
|
+
A rejected `sequenceIssues` intent — the input failed validation (empty/oversized `issues`, an
|
|
2511
|
+
unparseable `owner/repo#N` reference, or an unknown target/probe per the delivery-graph
|
|
2512
|
+
vocabulary). Every failure is path-qualified so the caller can fix the exact offending input.
|
|
2513
|
+
Nothing was generated or staged.
|
|
2514
|
+
type: object
|
|
2515
|
+
additionalProperties: false
|
|
2516
|
+
required:
|
|
2517
|
+
- error
|
|
2518
|
+
- issues
|
|
2519
|
+
properties:
|
|
2520
|
+
error:
|
|
2521
|
+
type: string
|
|
2522
|
+
description: A human-readable summary of why the intent was rejected.
|
|
2523
|
+
issues:
|
|
2524
|
+
type: array
|
|
2525
|
+
items:
|
|
2526
|
+
$ref: "#/components/schemas/DeliveryCompileError"
|
|
2527
|
+
description: The path-qualified validation failures (at least one).
|
|
2463
2528
|
FeatureStart:
|
|
2464
2529
|
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
2465
2530
|
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
|
@@ -3492,6 +3557,83 @@ paths:
|
|
|
3492
3557
|
application/json:
|
|
3493
3558
|
schema:
|
|
3494
3559
|
$ref: "#/components/schemas/ErrorBody"
|
|
3560
|
+
/actions/start/sequence-issues:
|
|
3561
|
+
post:
|
|
3562
|
+
operationId: sequenceIssues
|
|
3563
|
+
summary: Generate + STAGE the canonical "implement issue → converge → merge" delivery graph from a high-level intent (never dispatches). (epic #605 / S4)
|
|
3564
|
+
description: >-
|
|
3565
|
+
An INTENT-SHAPED door (epic nano-workforce#605, S4). ADR 0005's delivery graph is a closed
|
|
3566
|
+
vocabulary and operator-guide §9.4 already names the canonical shape for "implement issue →
|
|
3567
|
+
converge → merge", but an agent still had to hand-author the full node/edge JSON — sequencing
|
|
3568
|
+
four issues behind a gate meant constructing 13 nodes and 12 edges by hand. This door takes the
|
|
3569
|
+
high-level intent `{ behind?, issues[] }` and GENERATES that canonical graph, then STAGES it
|
|
3570
|
+
through the SAME compile+stage flow the raw `compileDeliveryGraph` door uses (one compiler, one
|
|
3571
|
+
staging path — idempotency + digest inherited, not re-implemented). It returns a preview and a
|
|
3572
|
+
navigational `reviewUrl` and NOTHING that can trigger a run: dispatch is an OPERATOR action in
|
|
3573
|
+
the cockpit (ADR 0005 Decision 7 / issue #460).
|
|
3574
|
+
|
|
3575
|
+
|
|
3576
|
+
For each issue it emits the canonical chain — `agent` (`senior:feature`, emits a typed `pr`
|
|
3577
|
+
fact) → `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic
|
|
3578
|
+
`poll.timeoutMs`) — and threads the `pr` fact along fact-qualified edges (§9.4). The issues run
|
|
3579
|
+
in SEQUENCE: each issue's implementation starts once the PRIOR issue has merged. When `behind`
|
|
3580
|
+
is given, a leading `wait[epic]` gate (§9.5) makes the whole sequence wait for that reference to
|
|
3581
|
+
be fully merged first.
|
|
3582
|
+
|
|
3583
|
+
|
|
3584
|
+
INPUT — the intent OBJECT `{ "issues": ["owner/repo#A", …] }` with an OPTIONAL
|
|
3585
|
+
`"behind": "owner/repo#NN"` gate (this is the object-body door). SIDE EFFECTS — impure: a valid intent is STAGED as a proposal.
|
|
3586
|
+
IDEMPOTENCY — content-addressed by the compiled `digest` (an identical intent re-stages the same
|
|
3587
|
+
digest). VALIDATION — invalid input (empty `issues`, an unparseable `owner/repo#N` reference, an
|
|
3588
|
+
unknown target/probe per the S3 vocabulary) is a `400` with `issues: [{ path, message }]`;
|
|
3589
|
+
nothing is staged. NEXT — surface the returned `reviewUrl` to the operator; poll
|
|
3590
|
+
`listStagedProposals` to see the staged digest.
|
|
3591
|
+
requestBody:
|
|
3592
|
+
required: true
|
|
3593
|
+
content:
|
|
3594
|
+
application/json:
|
|
3595
|
+
schema:
|
|
3596
|
+
# BEGIN generated:mcp-body source=#/components/schemas/SequenceIssuesIntent (scripts/inline-mcp-bodies.ts — do not hand-edit)
|
|
3597
|
+
description: 'The `sequenceIssues` INTENT (epic nano-workforce#605, S4) — a high-level shape that GENERATES the canonical "implement issue → converge → merge" delivery graph (operator-guide §9.4) instead of making an agent hand-author its node/edge JSON. It names an ordered list of `issues` to sequence (each issue''s implementation starts once the PRIOR issue has merged) and an OPTIONAL leading `behind` gate (wait for that issue/epic/feature to be fully merged first, §9.5). The door GENERATES, then STAGES the graph through the same compile+stage flow as `compileDeliveryGraph` — it never dispatches (dispatch is an operator-only cockpit action, ADR 0005 Decision 7). For each issue it emits `agent` (`senior:feature`, emits a `pr` fact) → `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic `poll.timeoutMs`), threading the `pr` fact per §9.4. Invalid input (empty `issues`, an unparseable ref) is a 400 carrying `issues: [{ path, message }]`; nothing is staged.'
|
|
3598
|
+
type: object
|
|
3599
|
+
additionalProperties: false
|
|
3600
|
+
example:
|
|
3601
|
+
behind: nanobpm/nano-ide#488
|
|
3602
|
+
issues:
|
|
3603
|
+
- nanobpm/nano-workforce#567
|
|
3604
|
+
- nanobpm/nano-workforce#568
|
|
3605
|
+
required:
|
|
3606
|
+
- issues
|
|
3607
|
+
properties:
|
|
3608
|
+
behind:
|
|
3609
|
+
type: string
|
|
3610
|
+
minLength: 1
|
|
3611
|
+
maxLength: 255
|
|
3612
|
+
description: OPTIONAL gate — an `owner/repo#NN` issue/epic/feature reference. When present, a leading `wait[epic]` node gates the whole sequence on that reference reaching "fully merged" (every opened slice/PR landed, §9.5) before the first issue's implementation starts.
|
|
3613
|
+
issues:
|
|
3614
|
+
type: array
|
|
3615
|
+
minItems: 1
|
|
3616
|
+
maxItems: 64
|
|
3617
|
+
items:
|
|
3618
|
+
type: string
|
|
3619
|
+
minLength: 1
|
|
3620
|
+
maxLength: 255
|
|
3621
|
+
description: An `owner/repo#N` issue reference to implement + converge + merge, in sequence.
|
|
3622
|
+
description: The ordered issues to sequence — each is implemented by a `senior:feature` agent that opens a PR, driven to convergence + merge, and the NEXT issue's implementation starts only once the prior issue has merged. At least one; at most 64 (keeps the generated graph within the compiler's node ceiling).
|
|
3623
|
+
# END generated:mcp-body
|
|
3624
|
+
responses:
|
|
3625
|
+
"200":
|
|
3626
|
+
description: The intent generated a valid delivery graph — it compiled and is STAGED for operator review; the response carries a preview and a navigational reviewUrl (no dispatch handle).
|
|
3627
|
+
content:
|
|
3628
|
+
application/json:
|
|
3629
|
+
schema:
|
|
3630
|
+
$ref: "#/components/schemas/CompileDeliveryGraphStaged"
|
|
3631
|
+
"400":
|
|
3632
|
+
description: The intent was invalid (empty/oversized issues, an unparseable reference, or an unknown target/probe) — path-qualified issues, nothing generated or staged.
|
|
3633
|
+
content:
|
|
3634
|
+
application/json:
|
|
3635
|
+
schema:
|
|
3636
|
+
$ref: "#/components/schemas/SequenceIssuesRejected"
|
|
3495
3637
|
/actions/compile-delivery-graph:
|
|
3496
3638
|
post:
|
|
3497
3639
|
operationId: compileDeliveryGraph
|
|
@@ -12,74 +12,36 @@
|
|
|
12
12
|
// cockpit; the response tells the agent its role ends here, turning the boundary into a self-documenting
|
|
13
13
|
// protocol. A malformed graph is a 400 carrying path-qualified errors; nothing is staged.
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
buildProposalPreview,
|
|
18
|
-
buildProposalRow,
|
|
19
|
-
proposalLogicalKey,
|
|
20
|
-
proposalReviewUrl,
|
|
21
|
-
stageProposal,
|
|
22
|
-
} from "../app/deliveryGraphProposals.ts";
|
|
23
|
-
import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
|
|
15
|
+
import { compileAndStageDeliveryGraph } from "../app/deliveryGraphStage.ts";
|
|
24
16
|
import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
|
|
25
17
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
26
18
|
|
|
27
|
-
const STAGED_MESSAGE =
|
|
28
|
-
"The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
|
|
29
|
-
|
|
30
19
|
export default defineOperation("compileDeliveryGraph", async ({ body, req }, app) => {
|
|
31
|
-
// The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the
|
|
32
|
-
// the SEMANTIC checks (acyclicity, edge integrity, fact resolution)
|
|
33
|
-
//
|
|
34
|
-
// `
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Persist the compiled graph as a `staged` proposal — the agent's surface ends here. Superseded by
|
|
42
|
-
// logical key + TTL inside `stageProposal`.
|
|
43
|
-
const digest = deliveryGraphDigest(result.bpmn);
|
|
44
|
-
const name =
|
|
45
|
-
typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
|
|
46
|
-
? result.resolved.name.trim()
|
|
47
|
-
: null;
|
|
48
|
-
const preview = buildProposalPreview(result);
|
|
49
|
-
await stageProposal(
|
|
20
|
+
// The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the shared
|
|
21
|
+
// compile+stage flow adds the SEMANTIC checks (acyclicity, edge integrity, fact resolution) and,
|
|
22
|
+
// when valid, persists the compiled graph as a `staged` proposal. A directly-invoked delegate could
|
|
23
|
+
// still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
|
|
24
|
+
// `ok:false`, never a 500. The navigational `reviewUrl` is keyed to the ORIGIN this request arrived
|
|
25
|
+
// on (tunnel, proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the
|
|
26
|
+
// operator driving this instance can actually open it (#577).
|
|
27
|
+
const staged = await compileAndStageDeliveryGraph(
|
|
50
28
|
app.data,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
title: name,
|
|
55
|
-
graphJson: JSON.stringify(body),
|
|
56
|
-
preview,
|
|
57
|
-
nodeCount: result.resolved.nodes.length,
|
|
58
|
-
humanNodeCount: result.humanNodes.length,
|
|
59
|
-
sideEffectCount: result.sideEffects.length,
|
|
60
|
-
sideEffecting: result.sideEffects.length > 0,
|
|
61
|
-
}),
|
|
29
|
+
body,
|
|
30
|
+
JSON.stringify(body),
|
|
31
|
+
resolvePublicOrigin(req),
|
|
62
32
|
);
|
|
33
|
+
if (!staged.ok) {
|
|
34
|
+
app.log.warn("compile-delivery-graph rejected", { errors: staged.body.errors.length });
|
|
35
|
+
return { status: 400, body: staged.body };
|
|
36
|
+
}
|
|
63
37
|
|
|
64
38
|
app.log.info("compile-delivery-graph staged", {
|
|
65
|
-
digest,
|
|
66
|
-
nodes:
|
|
67
|
-
humanNodes:
|
|
68
|
-
sideEffects:
|
|
39
|
+
digest: staged.digest,
|
|
40
|
+
nodes: staged.nodeCount,
|
|
41
|
+
humanNodes: staged.humanNodeCount,
|
|
42
|
+
sideEffects: staged.sideEffectCount,
|
|
69
43
|
});
|
|
70
44
|
|
|
71
45
|
// The response carries a preview + a navigational pointer and NO dispatch handle (issue #460).
|
|
72
|
-
return {
|
|
73
|
-
status: 200,
|
|
74
|
-
body: {
|
|
75
|
-
status: "ready",
|
|
76
|
-
message: STAGED_MESSAGE,
|
|
77
|
-
digest,
|
|
78
|
-
preview,
|
|
79
|
-
// Navigational, human-facing link → keyed to the ORIGIN this request arrived on (tunnel,
|
|
80
|
-
// proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the operator
|
|
81
|
-
// driving this instance can actually open it (#577).
|
|
82
|
-
reviewUrl: proposalReviewUrl(digest, resolvePublicOrigin(req)),
|
|
83
|
-
},
|
|
84
|
-
};
|
|
46
|
+
return { status: staged.status, body: staged.body };
|
|
85
47
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Tests for the POST /app/api/actions/start/sequence-issues operation `sequenceIssues` (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). The intent-shaped door GENERATES the canonical delivery graph and
|
|
3
|
+
// STAGES it through the SAME compile+stage flow as `compileDeliveryGraph` — the response carries only
|
|
4
|
+
// a preview + a navigational `reviewUrl` (no dispatch handle); dispatch stays an operator action.
|
|
5
|
+
// Invalid input is a 400 with `issues[{path,message}]` and nothing is staged.
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
12
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
13
|
+
import { deliveryGraphProposals, listStagedProposals } from "../app/deliveryGraphProposals.ts";
|
|
14
|
+
import { noopLog } from "../test/log.ts";
|
|
15
|
+
import handler from "./sequenceIssues.ts";
|
|
16
|
+
|
|
17
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
18
|
+
|
|
19
|
+
async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-seqissues-"));
|
|
21
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
22
|
+
try {
|
|
23
|
+
const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
|
|
24
|
+
await fn(edge, app.db);
|
|
25
|
+
} finally {
|
|
26
|
+
await app.stop?.();
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}) {
|
|
32
|
+
const req = { path: "/app/api/actions/start/sequence-issues", headers: new Headers(headers) };
|
|
33
|
+
return (await handler({ req: req as any, params: {}, query: {}, body } as any, app)) as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("sequence-issues: a valid intent → 200 ready, staged as a proposal, with the preview", async () => {
|
|
37
|
+
await withApp(async (app, data) => {
|
|
38
|
+
const res = await call(app, { behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2"] });
|
|
39
|
+
assertEquals(res.status, 200);
|
|
40
|
+
assertEquals(res.body.status, "ready");
|
|
41
|
+
assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
|
|
42
|
+
assert(typeof res.body.preview === "object" && res.body.preview !== null);
|
|
43
|
+
assert(typeof res.body.preview.diagram === "string" && res.body.preview.diagram.length > 0);
|
|
44
|
+
// Staged through the SAME path as compileDeliveryGraph — visible immediately (read-after-write).
|
|
45
|
+
const row = await deliveryGraphProposals(data).get(res.body.digest);
|
|
46
|
+
assert(row, "the generated graph is staged for operator dispatch");
|
|
47
|
+
assertEquals(row?.status, "staged");
|
|
48
|
+
const live = await listStagedProposals(data);
|
|
49
|
+
assert(live.some((p) => p.digest === res.body.digest), "the staged digest is listed");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("sequence-issues: the response exposes NO dispatch handle — the door stages, never dispatches", async () => {
|
|
54
|
+
await withApp(async (app) => {
|
|
55
|
+
const res = await call(app, { issues: ["acme/repo#1"] });
|
|
56
|
+
assertEquals(res.status, 200);
|
|
57
|
+
const keys = Object.keys(res.body);
|
|
58
|
+
for (const forbidden of ["runKey", "token", "approvalToken", "processInstanceKey", "processKey", "dispatchUrl"]) {
|
|
59
|
+
assert(!keys.includes(forbidden), `response must not carry a dispatch handle (${forbidden})`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("sequence-issues: an identical intent re-stages the SAME digest (idempotent), not a duplicate", async () => {
|
|
65
|
+
await withApp(async (app, data) => {
|
|
66
|
+
const a = await call(app, { issues: ["acme/repo#1", "acme/repo#2"] });
|
|
67
|
+
const b = await call(app, { issues: ["acme/repo#1", "acme/repo#2"] });
|
|
68
|
+
assertEquals(a.body.digest, b.body.digest);
|
|
69
|
+
const live = await listStagedProposals(data);
|
|
70
|
+
assertEquals(live.filter((p) => p.digest === a.body.digest).length, 1);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("sequence-issues: empty issues → 400 with issues[{path,message}], nothing staged", async () => {
|
|
75
|
+
await withApp(async (app, data) => {
|
|
76
|
+
const res = await call(app, { issues: [] });
|
|
77
|
+
assertEquals(res.status, 400);
|
|
78
|
+
assert(Array.isArray(res.body.issues) && res.body.issues.length > 0);
|
|
79
|
+
for (const iss of res.body.issues) {
|
|
80
|
+
assert(typeof iss.path === "string" && typeof iss.message === "string");
|
|
81
|
+
}
|
|
82
|
+
assertEquals((await listStagedProposals(data)).length, 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("sequence-issues: an unparseable issue ref → 400 at the offending path, nothing staged", async () => {
|
|
87
|
+
await withApp(async (app, data) => {
|
|
88
|
+
const res = await call(app, { issues: ["acme/repo#1", "garbage"] });
|
|
89
|
+
assertEquals(res.status, 400);
|
|
90
|
+
assert(res.body.issues.some((i: any) => i.path === "issues[1]"));
|
|
91
|
+
assertEquals((await listStagedProposals(data)).length, 0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("sequence-issues: a missing body folds into the same 400 contract (not a 500)", async () => {
|
|
96
|
+
await withApp(async (app) => {
|
|
97
|
+
const res = await call(app, undefined);
|
|
98
|
+
assertEquals(res.status, 400);
|
|
99
|
+
assert(Array.isArray(res.body.issues) && res.body.issues.length > 0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// POST /app/api/actions/start/sequence-issues → operationId `sequenceIssues` (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). An INTENT-SHAPED door: instead of making an agent hand-author the
|
|
3
|
+
// full canonical node/edge JSON for "implement issue → converge → merge" (§9.4 — 13 nodes + 12 edges
|
|
4
|
+
// for four gated issues, in the evidence session), it takes the high-level intent
|
|
5
|
+
// `{ behind?, issues[] }` and GENERATES that canonical delivery graph, then hands it to the SAME
|
|
6
|
+
// compile+stage flow the raw `compileDeliveryGraph` door uses. It STAGES for operator review and
|
|
7
|
+
// returns a navigational `reviewUrl` and NOTHING that can trigger a run — dispatch stays an
|
|
8
|
+
// operator-only cockpit action (ADR 0005 Decision 7 / issue #460). No new runner, no second staging
|
|
9
|
+
// path: the generator only produces the `DeliveryGraph` (`buildSequenceGraph`) and delegates.
|
|
10
|
+
//
|
|
11
|
+
// Invalid input (empty `issues`, an unparseable `owner/repo#N` ref, an unknown target/probe per the
|
|
12
|
+
// S3 vocabulary) is a 400 carrying the uniform `issues[{path,message}]` contract; nothing is staged.
|
|
13
|
+
import { compileAndStageDeliveryGraph } from "../app/deliveryGraphStage.ts";
|
|
14
|
+
import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
|
|
15
|
+
import { buildSequenceGraph } from "../app/sequenceIssues.ts";
|
|
16
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
|
+
|
|
18
|
+
export default defineOperation("sequenceIssues", async ({ body, req }, app) => {
|
|
19
|
+
// Build the canonical graph from the intent. Input validation (shape, ref format, vocabulary drift)
|
|
20
|
+
// lives in the pure `buildSequenceGraph` and returns the uniform `issues[{path,message}]` contract —
|
|
21
|
+
// a directly-invoked delegate passing `undefined` folds into the same clean rejection.
|
|
22
|
+
const built = buildSequenceGraph(body);
|
|
23
|
+
if (!built.ok) {
|
|
24
|
+
app.log.warn("sequence-issues rejected", { issues: built.issues.length });
|
|
25
|
+
return { status: 400, body: { error: "invalid sequenceIssues intent", issues: built.issues } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Hand the CONSTRUCTED graph to the shared compile+stage flow — one compiler, one staging path,
|
|
29
|
+
// idempotency/digest inherited (AGENTS.md "no drift surfaces"). `reviewUrl` keys to the origin this
|
|
30
|
+
// request arrived on (tunnel/proxy prefix), not the static deployment-wide base (#577).
|
|
31
|
+
const staged = await compileAndStageDeliveryGraph(
|
|
32
|
+
app.data,
|
|
33
|
+
built.graph,
|
|
34
|
+
JSON.stringify(built.graph),
|
|
35
|
+
resolvePublicOrigin(req),
|
|
36
|
+
);
|
|
37
|
+
if (!staged.ok) {
|
|
38
|
+
// A generated graph is well-formed by construction; a compile failure here is a generator defect,
|
|
39
|
+
// surfaced through the SAME `issues[{path,message}]` contract (mapped from the compiler's
|
|
40
|
+
// path-qualified `errors`) rather than a 500.
|
|
41
|
+
app.log.error("sequence-issues compile failed on a generated graph", { errors: staged.body.errors.length });
|
|
42
|
+
return {
|
|
43
|
+
status: 400,
|
|
44
|
+
body: { error: "generated delivery graph failed to compile", issues: staged.body.errors },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
app.log.info("sequence-issues staged", {
|
|
49
|
+
digest: staged.digest,
|
|
50
|
+
nodes: staged.nodeCount,
|
|
51
|
+
humanNodes: staged.humanNodeCount,
|
|
52
|
+
sideEffects: staged.sideEffectCount,
|
|
53
|
+
});
|
|
54
|
+
return { status: staged.status, body: staged.body };
|
|
55
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.162.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|