@nanobpm/nano-workforce 0.159.0 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraphProposals.test.ts +16 -0
- package/app/deliveryGraphProposals.ts +33 -13
- package/app/deliveryGraphVocabulary.test.ts +102 -0
- package/app/deliveryGraphVocabulary.ts +319 -0
- package/app/readiness.ts +10 -5
- package/docs/agent-guide.md +15 -0
- package/e2e/delivery-graph-read-after-write.e2e.ts +132 -0
- package/openapi.yaml +275 -5
- package/operations/getDeliveryGraphVocabulary.ts +25 -0
- package/package.json +1 -1
- package/test/deliveryGraphVocabulary-mcp.test.ts +62 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.160.0](https://github.com/nanobpm/nano-workforce/compare/v0.159.1...v0.160.0) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** getDeliveryGraphVocabulary read tool — closed vocabulary + wait semantics as structured data (S3) ([#616](https://github.com/nanobpm/nano-workforce/issues/616)) ([8d3e90a](https://github.com/nanobpm/nano-workforce/commit/8d3e90a294f9ed195c5cc1b7a30cffb1799a053a)), closes [nanobpm/nano-workforce#605](https://github.com/nanobpm/nano-workforce/issues/605) [#609](https://github.com/nanobpm/nano-workforce/issues/609)
|
|
6
|
+
|
|
7
|
+
## [0.159.1](https://github.com/nanobpm/nano-workforce/compare/v0.159.0...v0.159.1) (2026-08-29)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** read-after-write consistency for listStagedProposals (S2) ([#617](https://github.com/nanobpm/nano-workforce/issues/617)) ([bd0246c](https://github.com/nanobpm/nano-workforce/commit/bd0246cc81241041320aa483b0a0789f5e372031)), closes [#608](https://github.com/nanobpm/nano-workforce/issues/608)
|
|
12
|
+
|
|
1
13
|
## [0.159.0](https://github.com/nanobpm/nano-workforce/compare/v0.158.1...v0.159.0) (2026-08-29)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DELIVERY_PROPOSAL_TTL_MS,
|
|
16
16
|
deliveryGraphProposals,
|
|
17
17
|
getStagedProposal,
|
|
18
|
+
isLiveStaged,
|
|
18
19
|
isProposalExpired,
|
|
19
20
|
markProposalDismissed,
|
|
20
21
|
markProposalDispatched,
|
|
@@ -82,6 +83,21 @@ test("isProposalExpired: past → true, future → false, blank/corrupt → true
|
|
|
82
83
|
assertEquals(isProposalExpired("garbage", at), true);
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
test("isLiveStaged: the ONE liveness predicate — only a `staged`, not-yet-expired row is live (#608)", () => {
|
|
87
|
+
const at = new Date("2024-06-01T00:00:00.000Z");
|
|
88
|
+
const live = row({ createdAt: "2024-05-31T23:00:00.000Z" }); // staged, TTL a day out → live
|
|
89
|
+
assertEquals(isLiveStaged(live, at), true);
|
|
90
|
+
// A future-created staged row is trivially live too (TTL further out).
|
|
91
|
+
assertEquals(isLiveStaged(row(), new Date()), true);
|
|
92
|
+
// Every non-`staged` status is NOT live, regardless of TTL.
|
|
93
|
+
for (const status of ["superseded", "dispatched", "expired", "dismissed"] as const) {
|
|
94
|
+
assertEquals(isLiveStaged({ ...live, status }, at), false);
|
|
95
|
+
}
|
|
96
|
+
// A `staged` row whose TTL has elapsed is NOT live (mirrors isProposalExpired, fail-closed).
|
|
97
|
+
assertEquals(isLiveStaged({ ...live, expires_at: "2024-05-30T00:00:00.000Z" }, at), false);
|
|
98
|
+
assertEquals(isLiveStaged({ ...live, expires_at: "" }, at), false);
|
|
99
|
+
});
|
|
100
|
+
|
|
85
101
|
test("proposalReviewUrl: a navigational deep-link to the cockpit page — NOT a dispatch endpoint", () => {
|
|
86
102
|
const url = proposalReviewUrl("abc123", "https://cockpit.example");
|
|
87
103
|
assertEquals(url, "https://cockpit.example/app/pages/delivery-graphs#proposal-abc123");
|
|
@@ -207,9 +207,25 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
|
|
|
207
207
|
return toWrite;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
210
|
+
/** The ONE definition of "a live, dispatchable-RIGHT-NOW staged proposal" (issue #608): the row is
|
|
211
|
+
* `staged` (not superseded / dispatched / expired / dismissed) AND has not aged out of its TTL. This is
|
|
212
|
+
* the SINGLE SOURCE OF TRUTH the digest read (`getStagedProposal`), the list read
|
|
213
|
+
* (`listStagedProposals`), and — through them — the cockpit App-View, the dispatch/preview/dismiss
|
|
214
|
+
* doors, and the MCP `listStagedProposals` tool all resolve liveness through, so no two readers can ever
|
|
215
|
+
* drift on which rows are live (AGENTS.md "Derivation over duplication"). A read-after-write from
|
|
216
|
+
* `compileDeliveryGraph` is trustworthy because this predicate is evaluated over the SAME durable
|
|
217
|
+
* `delivery_graph_proposals` store the compile door commits the row to (the app's single default
|
|
218
|
+
* `app.data` source) — a freshly staged row (`status: 'staged'`, `expires_at` a full TTL in the future)
|
|
219
|
+
* is live by construction, with no projection/read-model between the write and the read to lag behind. */
|
|
220
|
+
export function isLiveStaged(row: DeliveryGraphProposal, at: Date = new Date()): boolean {
|
|
221
|
+
return row.status === "staged" && !isProposalExpired(row.expires_at, at);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Load a proposal that is live and dispatchable RIGHT NOW: it exists and satisfies {@link isLiveStaged}
|
|
225
|
+
* (it is `staged` — not superseded or already dispatched — and has not aged out of its TTL). Returns
|
|
226
|
+
* null otherwise, so the cockpit dispatch action refuses a stale/unknown/already-dispatched digest
|
|
227
|
+
* cleanly. Reads the authoritative `delivery_graph_proposals` row by primary key from the same
|
|
228
|
+
* `app.data` store the compile door stages into — a read-your-writes lookup, no read-model in between. */
|
|
213
229
|
export async function getStagedProposal(
|
|
214
230
|
data: DataLayer,
|
|
215
231
|
digest: string,
|
|
@@ -217,24 +233,28 @@ export async function getStagedProposal(
|
|
|
217
233
|
): Promise<DeliveryGraphProposal | null> {
|
|
218
234
|
const row = await deliveryGraphProposals(data).get(digest);
|
|
219
235
|
if (!row) return null;
|
|
220
|
-
|
|
221
|
-
if (isProposalExpired(row.expires_at, at)) return null;
|
|
222
|
-
return row;
|
|
236
|
+
return isLiveStaged(row, at) ? row : null;
|
|
223
237
|
}
|
|
224
238
|
|
|
225
|
-
/** Every LIVE staged proposal — `status = 'staged'` AND not aged out of its TTL —
|
|
226
|
-
* staged App-View (`pages/delivery-graphs/staged.mount.js`)
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
239
|
+
/** Every LIVE staged proposal — {@link isLiveStaged} (`status = 'staged'` AND not aged out of its TTL) —
|
|
240
|
+
* newest first. The staged App-View (`pages/delivery-graphs/staged.mount.js`) and the MCP
|
|
241
|
+
* `listStagedProposals` tool both poll THIS door to render/answer the Preview-DI + Dispatch list.
|
|
242
|
+
*
|
|
243
|
+
* READ-AFTER-WRITE GUARANTEE (issue #608). The list is served by a fresh query against the authoritative
|
|
244
|
+
* `delivery_graph_proposals` table on the app's single default `app.data` source — the SAME store, in
|
|
245
|
+
* the SAME scope, the `compileDeliveryGraph`/`stageProposal` write commits to. There is no
|
|
246
|
+
* projection/read-model or cache between the write and this read, so a digest `compileDeliveryGraph`
|
|
247
|
+
* just returned is listed on the very next call with NO intervening delay (the write is committed before
|
|
248
|
+
* the compile door responds). Liveness is decided by the shared {@link isLiveStaged} predicate — NOT a
|
|
249
|
+
* second `status = 'staged'` datasource filter, which cannot express an `expires_at > now` cutoff and so
|
|
250
|
+
* would linger an aged-out row until the poller's sweep realises the TTL. Read-only; no write. */
|
|
231
251
|
export async function listStagedProposals(
|
|
232
252
|
data: DataLayer,
|
|
233
253
|
at: Date = new Date(),
|
|
234
254
|
): Promise<DeliveryGraphProposal[]> {
|
|
235
255
|
const rows = await deliveryGraphProposals(data).find({ status: "staged" });
|
|
236
256
|
return rows
|
|
237
|
-
.filter((row) =>
|
|
257
|
+
.filter((row) => isLiveStaged(row, at))
|
|
238
258
|
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
239
259
|
}
|
|
240
260
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// app/deliveryGraphVocabulary.test.ts — the DRIFT GUARD for the delivery-graph vocabulary surface
|
|
2
|
+
// (epic nano-workforce#605, S3/#609). The vocabulary (`getDeliveryGraphVocabulary`) exists so agents
|
|
3
|
+
// discover the closed node/probe/connector vocabulary from the surface instead of reading source; if
|
|
4
|
+
// a new probe kind or connector target lands in the compiler WITHOUT a matching vocabulary entry, the
|
|
5
|
+
// surface silently lies. These tests fail the build in exactly that case: they assert the vocabulary's
|
|
6
|
+
// key sets are byte-identical to the closed sets in `app/deliveryGraph.ts` / `app/readiness.ts` /
|
|
7
|
+
// `app/convergeTargets.ts` (AGENTS.md — "no drift surfaces").
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
|
|
11
|
+
import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
|
|
12
|
+
import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_EVERY_MS,
|
|
15
|
+
DEFAULT_TIMEOUT_MS,
|
|
16
|
+
EPIC_CONDITIONS,
|
|
17
|
+
ON_TIMEOUTS,
|
|
18
|
+
PR_CONDITIONS,
|
|
19
|
+
PROBE_KINDS,
|
|
20
|
+
} from "./readiness.ts";
|
|
21
|
+
|
|
22
|
+
const sorted = (xs: readonly string[]): string[] => [...xs].sort();
|
|
23
|
+
|
|
24
|
+
test("node kinds cover exactly DELIVERY_NODE_KINDS (add a kind to the compiler ⇒ must add a vocab entry)", () => {
|
|
25
|
+
const vocab = deliveryGraphVocabulary();
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
sorted(vocab.nodeKinds.map((n) => n.kind)),
|
|
28
|
+
sorted(DELIVERY_NODE_KINDS),
|
|
29
|
+
"vocabulary node kinds drifted from DELIVERY_NODE_KINDS — extend NODE_KIND_DETAIL",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("wait probe kinds cover exactly PROBE_KINDS (add a probe kind ⇒ must add a vocab entry)", () => {
|
|
34
|
+
const vocab = deliveryGraphVocabulary();
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
sorted(vocab.waitProbeKinds.map((p) => p.kind)),
|
|
37
|
+
sorted(PROBE_KINDS),
|
|
38
|
+
"vocabulary wait probe kinds drifted from PROBE_KINDS — extend WAIT_PROBE_DETAIL",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("pr / epic probe conditions match the closed PR_CONDITIONS / EPIC_CONDITIONS", () => {
|
|
43
|
+
const vocab = deliveryGraphVocabulary();
|
|
44
|
+
const pr = vocab.waitProbeKinds.find((p) => p.kind === "pr");
|
|
45
|
+
const epic = vocab.waitProbeKinds.find((p) => p.kind === "epic");
|
|
46
|
+
assert.ok(pr && epic, "pr and epic probe entries must exist");
|
|
47
|
+
assert.deepEqual(sorted(pr.conditions ?? []), sorted(PR_CONDITIONS), "pr conditions drifted from PR_CONDITIONS");
|
|
48
|
+
assert.deepEqual(sorted(epic.conditions ?? []), sorted(EPIC_CONDITIONS), "epic conditions drifted from EPIC_CONDITIONS");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("every real converge-enrollment target has a real vocab entry (add a target ⇒ must add a vocab entry)", () => {
|
|
52
|
+
const vocab = deliveryGraphVocabulary();
|
|
53
|
+
const realTargets = vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target);
|
|
54
|
+
for (const target of [CONVERGE_TARGET, CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET]) {
|
|
55
|
+
assert.ok(
|
|
56
|
+
realTargets.includes(target),
|
|
57
|
+
`converge target '${target}' is missing a 'real' vocabulary entry — extend REAL_CONNECTOR_TARGETS`,
|
|
58
|
+
);
|
|
59
|
+
// Guard the classification too: a target the compiler treats as converge-enrollment must be marked real.
|
|
60
|
+
assert.ok(isConvergeTarget(target), `sanity: '${target}' must be an isConvergeTarget`);
|
|
61
|
+
}
|
|
62
|
+
// Exactly the converge set is "real"; nothing else is claimed real, and the stub sentinel is present.
|
|
63
|
+
assert.deepEqual(sorted(realTargets), sorted([CONVERGE_TARGET, CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET]));
|
|
64
|
+
assert.ok(
|
|
65
|
+
vocab.connectorTargets.some((t) => t.status === "forward-declared"),
|
|
66
|
+
"the forward-declared stub sentinel must be present so agents learn the real-vs-stub split",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("onTimeout options match the closed ON_TIMEOUTS", () => {
|
|
71
|
+
const vocab = deliveryGraphVocabulary();
|
|
72
|
+
assert.deepEqual(sorted(vocab.onTimeout.map((o) => o.value)), sorted(ON_TIMEOUTS), "onTimeout options drifted from ON_TIMEOUTS");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("fact types + guard scalar types are derived verbatim", () => {
|
|
76
|
+
const vocab = deliveryGraphVocabulary();
|
|
77
|
+
assert.deepEqual(vocab.factTypes, [...DELIVERY_FACT_TYPES]);
|
|
78
|
+
assert.deepEqual(vocab.guardScalarTypes, [...DELIVERY_GUARD_SCALAR_TYPES]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("poll-budget carries the real defaults and names the 30-minute trap", () => {
|
|
82
|
+
const vocab = deliveryGraphVocabulary();
|
|
83
|
+
assert.equal(vocab.pollBudget.defaultTimeoutMs, DEFAULT_TIMEOUT_MS);
|
|
84
|
+
assert.equal(vocab.pollBudget.defaultEveryMs, DEFAULT_EVERY_MS);
|
|
85
|
+
assert.match(vocab.pollBudget.rule, /poll\.timeoutMs/);
|
|
86
|
+
assert.match(vocab.pollBudget.rule, /30 minutes|1800000/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the epic probe states the FEATURE-RUN observation semantics (the #605 evidence gap)", () => {
|
|
90
|
+
const vocab = deliveryGraphVocabulary();
|
|
91
|
+
const epic = vocab.waitProbeKinds.find((p) => p.kind === "epic");
|
|
92
|
+
assert.ok(epic, "epic probe entry must exist");
|
|
93
|
+
assert.match(epic.observes, /rootRequestKey/i);
|
|
94
|
+
assert.match(epic.observes, /regardless of/i);
|
|
95
|
+
assert.match(epic.observes, /feature/i);
|
|
96
|
+
assert.match(epic.ready, /stage:"merged"|stage:\\"merged\\"|merged.*active:false/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("fact-threading rule names the unbound-pr rejection", () => {
|
|
100
|
+
const vocab = deliveryGraphVocabulary();
|
|
101
|
+
assert.match(vocab.factThreading.rule, /unbound-pr/);
|
|
102
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// app/deliveryGraphVocabulary.ts — the delivery-graph vocabulary + wait-probe semantics as
|
|
2
|
+
// STRUCTURED DATA (epic nano-workforce#605, S3/#609). Served by GET /app/api/delivery-graph/vocabulary
|
|
3
|
+
// (operationId `getDeliveryGraphVocabulary`, a read tool projected onto the MCP surface like
|
|
4
|
+
// `getAgentInstructions`), so an agent can DISCOVER the closed node/probe/connector vocabulary and the
|
|
5
|
+
// non-obvious wait semantics from the surface instead of reading source (the evidence session in #605:
|
|
6
|
+
// an agent had to grep `app/readiness.ts` to learn `wait[epic]` also gates a feature run).
|
|
7
|
+
//
|
|
8
|
+
// Derivation over duplication (AGENTS.md — "no drift surfaces"). Everything that has a closed,
|
|
9
|
+
// compiler-enforced source of truth is DERIVED from it, never re-typed:
|
|
10
|
+
// • node kinds ← `DELIVERY_NODE_KINDS` (app/deliveryGraph.ts — the trust boundary)
|
|
11
|
+
// • fact types ← `DELIVERY_FACT_TYPES` (app/deliveryGraph.ts)
|
|
12
|
+
// • guardable scalars ← `DELIVERY_GUARD_SCALAR_TYPES`
|
|
13
|
+
// • wait probe kinds ← `PROBE_KINDS` (app/readiness.ts — what `parseProbe` accepts)
|
|
14
|
+
// • pr conditions ← `PR_CONDITIONS` (app/readiness.ts)
|
|
15
|
+
// • epic conditions ← `EPIC_CONDITIONS` (app/readiness.ts)
|
|
16
|
+
// • onTimeout options ← `ON_TIMEOUTS` (app/readiness.ts)
|
|
17
|
+
// • poll defaults ← `DEFAULT_TIMEOUT_MS`/`DEFAULT_EVERY_MS`/`DEFAULT_READINESS_TIMEOUT`
|
|
18
|
+
// • real connector targets ← `converge`/`converge-merge`/`merge-main` (app/convergeTargets.ts)
|
|
19
|
+
// The prose (body contracts, what each probe OBSERVES, the poll-budget trap, fact-threading) is
|
|
20
|
+
// co-located here; `app/deliveryGraphVocabulary.test.ts` is the drift guard — it fails the build if a
|
|
21
|
+
// probe kind / connector target / node kind is added to the compiler without a vocabulary entry.
|
|
22
|
+
import {
|
|
23
|
+
CONVERGE_MERGE_TARGET,
|
|
24
|
+
CONVERGE_TARGET,
|
|
25
|
+
convergeOnlyForTarget,
|
|
26
|
+
MERGE_MAIN_TARGET,
|
|
27
|
+
} from "./convergeTargets.ts";
|
|
28
|
+
import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_EVERY_MS,
|
|
31
|
+
DEFAULT_READINESS_TIMEOUT,
|
|
32
|
+
DEFAULT_TIMEOUT_MS,
|
|
33
|
+
EPIC_CONDITIONS,
|
|
34
|
+
ON_TIMEOUTS,
|
|
35
|
+
PR_CONDITIONS,
|
|
36
|
+
PROBE_KINDS,
|
|
37
|
+
} from "./readiness.ts";
|
|
38
|
+
|
|
39
|
+
/** A node-kind entry: the closed `kind`, its per-kind config key + required/optional body fields, and
|
|
40
|
+
* whether it is side-effecting / may emit facts. `body` names the executable engine-native surface the
|
|
41
|
+
* graph layer schedules onto (the graph layer does NOT re-implement execution). */
|
|
42
|
+
export interface NodeKindEntry {
|
|
43
|
+
kind: string;
|
|
44
|
+
configKey: string;
|
|
45
|
+
requiredFields: string[];
|
|
46
|
+
optionalFields: string[];
|
|
47
|
+
sideEffecting: boolean;
|
|
48
|
+
mayEmit: boolean;
|
|
49
|
+
summary: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A wait-probe entry: the closed `kind`, the `match` fields it reads, and — crucially — WHAT it
|
|
53
|
+
* OBSERVES (the read that decides readiness) and WHEN it is ready. */
|
|
54
|
+
export interface WaitProbeEntry {
|
|
55
|
+
kind: string;
|
|
56
|
+
target: string;
|
|
57
|
+
matchFields: string[];
|
|
58
|
+
conditions?: string[];
|
|
59
|
+
observes: string;
|
|
60
|
+
ready: string;
|
|
61
|
+
binds?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A connector target: whether it is a REAL side-effecting target (a converge-enrollment target that
|
|
65
|
+
* dispatches through `submitPr`) or a FORWARD-DECLARED stub (the connector I/O surface is an ADR 0005
|
|
66
|
+
* non-goal — an unrecognised target returns a deterministic acknowledgement and performs no I/O). */
|
|
67
|
+
export interface ConnectorTargetEntry {
|
|
68
|
+
target: string;
|
|
69
|
+
status: "real" | "forward-declared";
|
|
70
|
+
convergeOnlyDefault?: boolean;
|
|
71
|
+
summary: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An `onTimeout` option: what the bounded wait does when the engine timer arm fires. */
|
|
75
|
+
export interface OnTimeoutEntry {
|
|
76
|
+
value: string;
|
|
77
|
+
meaning: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The whole structured vocabulary the read tool returns. */
|
|
81
|
+
export interface DeliveryGraphVocabulary {
|
|
82
|
+
adr: string;
|
|
83
|
+
summary: string;
|
|
84
|
+
nodeKinds: NodeKindEntry[];
|
|
85
|
+
factTypes: string[];
|
|
86
|
+
guardScalarTypes: string[];
|
|
87
|
+
waitProbeKinds: WaitProbeEntry[];
|
|
88
|
+
connectorTargets: ConnectorTargetEntry[];
|
|
89
|
+
onTimeout: OnTimeoutEntry[];
|
|
90
|
+
pollBudget: {
|
|
91
|
+
defaultTimeoutMs: number;
|
|
92
|
+
defaultTimeoutIso: string;
|
|
93
|
+
defaultEveryMs: number;
|
|
94
|
+
rule: string;
|
|
95
|
+
};
|
|
96
|
+
factThreading: {
|
|
97
|
+
rule: string;
|
|
98
|
+
details: string[];
|
|
99
|
+
};
|
|
100
|
+
guideSection: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Node kinds (DERIVED from DELIVERY_NODE_KINDS — the closed allowlist / trust boundary) ─────────
|
|
104
|
+
const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
|
|
105
|
+
agent: {
|
|
106
|
+
configKey: "agent",
|
|
107
|
+
requiredFields: ["jobType"],
|
|
108
|
+
optionalFields: ["prompt", "converge", "merge"],
|
|
109
|
+
sideEffecting: true,
|
|
110
|
+
mayEmit: true,
|
|
111
|
+
summary:
|
|
112
|
+
"A worker runs an agent job type (the fan-out body, e.g. `senior:feature`). First-class " +
|
|
113
|
+
"`converge?`/`merge?` cell-policy flags declare review-convergence / landing intent (`merge` " +
|
|
114
|
+
"requires `converge`); a raw `senior:converge`/`senior:merge` jobType is rejected (`raw-converge-node`). " +
|
|
115
|
+
"An `agent` that opens a PR emits it as a `pr`-typed fact so downstream connector/wait nodes late-bind it.",
|
|
116
|
+
},
|
|
117
|
+
wait: {
|
|
118
|
+
configKey: "wait",
|
|
119
|
+
requiredFields: ["kind", "target"],
|
|
120
|
+
optionalFields: ["match", "poll", "onTimeout", "credentialEnv"],
|
|
121
|
+
sideEffecting: false,
|
|
122
|
+
mayEmit: true,
|
|
123
|
+
summary:
|
|
124
|
+
"A durable, bounded, read-only readiness probe (a `ReadinessProbe` verbatim). `wait.kind` selects " +
|
|
125
|
+
"the probe (see waitProbeKinds); `poll` is `{ everyMs?, timeoutMs?, backoff? }`. Binds observed " +
|
|
126
|
+
"facts (e.g. a merged `pr` binds `mergedSha`).",
|
|
127
|
+
},
|
|
128
|
+
human: {
|
|
129
|
+
configKey: "human",
|
|
130
|
+
requiredFields: [],
|
|
131
|
+
optionalFields: ["formKey", "prompt"],
|
|
132
|
+
sideEffecting: false,
|
|
133
|
+
mayEmit: true,
|
|
134
|
+
summary:
|
|
135
|
+
"A scheduled user task + form (the Tasks inbox). Blocks dependents, SLA-bounded, answerable by a " +
|
|
136
|
+
"human OR an agent. Config is optional (no required field).",
|
|
137
|
+
},
|
|
138
|
+
connector: {
|
|
139
|
+
configKey: "connector",
|
|
140
|
+
requiredFields: ["target"],
|
|
141
|
+
optionalFields: ["dedupeKey", "payload"],
|
|
142
|
+
sideEffecting: true,
|
|
143
|
+
mayEmit: true,
|
|
144
|
+
summary:
|
|
145
|
+
"An automated, side-effecting outbound action. `payload` for a converge target is " +
|
|
146
|
+
"`{ pr, convergeOnly?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
|
|
147
|
+
"reference, or omitted to auto-bind the single incoming `pr` fact). Carries a `dedupeKey` " +
|
|
148
|
+
"(at-least-once safe). See connectorTargets for which targets are real vs. forward-declared.",
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ── Wait probe kinds (DERIVED from PROBE_KINDS — what `parseProbe` accepts) ───────────────────────
|
|
153
|
+
const WAIT_PROBE_DETAIL: Record<string, Omit<WaitProbeEntry, "kind">> = {
|
|
154
|
+
http: {
|
|
155
|
+
target: "a URL",
|
|
156
|
+
matchFields: ["status", "bodyIncludes"],
|
|
157
|
+
observes: "an HTTP GET against `target` (optional `credentialEnv` supplies an Authorization credential by env-key name).",
|
|
158
|
+
ready: "the response status matches `match.status` (default: any 2xx) and the body contains `match.bodyIncludes` if set.",
|
|
159
|
+
},
|
|
160
|
+
command: {
|
|
161
|
+
target: "a shell command",
|
|
162
|
+
matchFields: ["exitCode", "stdoutIncludes"],
|
|
163
|
+
observes: "running `target` as a subprocess (the escape hatch for the long tail — `gh`, `curl`, `docker manifest inspect`).",
|
|
164
|
+
ready: "the exit code matches `match.exitCode` (default 0) and stdout contains `match.stdoutIncludes` if set.",
|
|
165
|
+
},
|
|
166
|
+
npm: {
|
|
167
|
+
target: "a `pkg@version` (or bare `pkg`)",
|
|
168
|
+
matchFields: ["version", "stdoutIncludes"],
|
|
169
|
+
observes: "the npm registry for a published version of the package.",
|
|
170
|
+
ready: "`match.version` (default: the version in `pkg@version`) is published.",
|
|
171
|
+
},
|
|
172
|
+
"github-check": {
|
|
173
|
+
target: "an `owner/repo@ref`",
|
|
174
|
+
matchFields: ["conclusion", "checkName"],
|
|
175
|
+
observes: "the GitHub check runs on `ref`.",
|
|
176
|
+
ready: "the check run's conclusion matches `match.conclusion` (default `success`), restricted to `match.checkName` if set.",
|
|
177
|
+
},
|
|
178
|
+
capability: {
|
|
179
|
+
target: "a package/context handle",
|
|
180
|
+
matchFields: ["capabilityRef", "package", "verifyCommand"],
|
|
181
|
+
observes:
|
|
182
|
+
"the publish-provenance substrate: which published `package` version first carries the `capabilityRef` " +
|
|
183
|
+
"issue/PR — an optional `verifyCommand` runs once at the poll-budget boundary as a gated empirical fallback.",
|
|
184
|
+
ready: "a published version of `match.package` carries `match.capabilityRef` in its provenance.",
|
|
185
|
+
binds: ["resolvedArtifact"],
|
|
186
|
+
},
|
|
187
|
+
pr: {
|
|
188
|
+
target: "an `owner/repo#N` PR (or a `<node>.pr` fact reference the compiler late-binds at dispatch)",
|
|
189
|
+
matchFields: ["prState"],
|
|
190
|
+
conditions: [...PR_CONDITIONS],
|
|
191
|
+
observes:
|
|
192
|
+
"the live GitHub state of a single in-flight PR. The ACTION (landing it) stays in a connector/merge " +
|
|
193
|
+
"node body; this kind only OBSERVES, so it is level-triggered (no missed edge).",
|
|
194
|
+
ready: "the PR reaches `match.prState` (default `merged`; one of the pr conditions).",
|
|
195
|
+
binds: ["mergedSha"],
|
|
196
|
+
},
|
|
197
|
+
epic: {
|
|
198
|
+
target:
|
|
199
|
+
"the epic's durable `planKey` — `owner/repo#NN`, the epic ISSUE, not the engine processInstanceKey, " +
|
|
200
|
+
"so a resubmit/replay still resolves (may also be a `<node>.fact` late-binding reference)",
|
|
201
|
+
matchFields: ["epicState"],
|
|
202
|
+
conditions: [...EPIC_CONDITIONS],
|
|
203
|
+
observes:
|
|
204
|
+
"the app's OWN lineage read-model (`/lineage?root=<planKey>`), resolved by `parseEpicLineage` to the " +
|
|
205
|
+
"thread whose `rootRequestKey` matches the planKey — REGARDLESS OF the thread's `kind` (feature | epic | " +
|
|
206
|
+
"pr | delivery). Because `app/lineage.ts` lands a FEATURE thread on `stage:\"merged\"` once its PR merges, " +
|
|
207
|
+
"`wait[epic]` gates a single-PR FEATURE RUN just as well as a plan-fanout epic: point `target` at the " +
|
|
208
|
+
"feature/epic root issue and it observes that thread's aggregate frontier. A failed/abandoned/mixed epic " +
|
|
209
|
+
"settles on another terminal (`abandoned`/`resolved`/`converged`) and never reports merged, so it never " +
|
|
210
|
+
"falsely releases the gate — the bounded wait routes via `onTimeout` instead of hanging.",
|
|
211
|
+
ready: "the lineage thread reaches `stage:\"merged\" && active:false` (every opened slice/PR landed). `match.epicState` (default `merged`; `done` is a synonym) both mean \"fully merged\".",
|
|
212
|
+
binds: ["prCount"],
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ── Connector targets (real = the converge-enrollment set from convergeTargets.ts) ────────────────
|
|
217
|
+
const REAL_CONNECTOR_TARGETS: ConnectorTargetEntry[] = [
|
|
218
|
+
{
|
|
219
|
+
target: CONVERGE_TARGET,
|
|
220
|
+
status: "real",
|
|
221
|
+
convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_TARGET),
|
|
222
|
+
summary: "Converge-only: drive review convergence and STOP at `converged`, never handing off to the merge loop.",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
target: CONVERGE_MERGE_TARGET,
|
|
226
|
+
status: "real",
|
|
227
|
+
convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_MERGE_TARGET),
|
|
228
|
+
summary:
|
|
229
|
+
"Unit-level land: drive review convergence AND the merge loop, landing the PR onto its OWN base branch " +
|
|
230
|
+
"(for a unit inside an epic that base is the epic integration branch, never `main` directly).",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
target: MERGE_MAIN_TARGET,
|
|
234
|
+
status: "real",
|
|
235
|
+
convergeOnlyDefault: convergeOnlyForTarget(MERGE_MAIN_TARGET),
|
|
236
|
+
summary:
|
|
237
|
+
"Graph-level top-level land (two-level merge, ADR 0006 §3): land the graph/epic INTEGRATION PR onto `main`. " +
|
|
238
|
+
"Dispatch-identical to `converge-merge`; the distinction is the LEVEL, kept a first-class literal.",
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
/** The sentinel that describes ANY non-converge target: the connector I/O surface is forward-declared
|
|
243
|
+
* (ADR 0005 non-goal), so an unrecognised `target` hits the default stub action and performs no real
|
|
244
|
+
* side effect. Included so a caller learns the real-vs-stub split without reading `deliveryConnector.ts`. */
|
|
245
|
+
const FORWARD_DECLARED_ENTRY: ConnectorTargetEntry = {
|
|
246
|
+
target: "<any other target>",
|
|
247
|
+
status: "forward-declared",
|
|
248
|
+
summary:
|
|
249
|
+
"Forward-declared stub: the concrete connector I/O scheme is an ADR 0005 non-goal. A target outside the " +
|
|
250
|
+
"converge-enrollment set returns a deterministic acknowledgement (`connector stub — I/O surface " +
|
|
251
|
+
"forward-declared`) and fires NO real side effect until a real action is injected.",
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const ON_TIMEOUT_DETAIL: Record<string, string> = {
|
|
255
|
+
escalate: "park a human-in-the-loop escalation (the Tasks inbox) when the bounded wait elapses; a human/agent decides whether to extend the budget or abandon.",
|
|
256
|
+
fail: "terminate the gate as failed. NOTE: not yet supported on a `wait` node (blocked on engine terminate-end wiring); the compiler rejects `onTimeout: fail` on a wait.",
|
|
257
|
+
continue: "proceed as if ready when the wait elapses — use ONLY when downstream can tolerate a not-yet-ready upstream (a soft gate).",
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** Build the structured delivery-graph vocabulary. Pure — no I/O; every closed set is imported from
|
|
261
|
+
* its owning module so this can never silently drift from what the compiler/runner actually accept. */
|
|
262
|
+
export function deliveryGraphVocabulary(): DeliveryGraphVocabulary {
|
|
263
|
+
const nodeKinds: NodeKindEntry[] = DELIVERY_NODE_KINDS.map((kind) => {
|
|
264
|
+
const detail = NODE_KIND_DETAIL[kind];
|
|
265
|
+
if (!detail) throw new Error(`deliveryGraphVocabulary: no detail for node kind '${kind}' (drift — extend NODE_KIND_DETAIL)`);
|
|
266
|
+
return { kind, ...detail };
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const waitProbeKinds: WaitProbeEntry[] = PROBE_KINDS.map((kind) => {
|
|
270
|
+
const detail = WAIT_PROBE_DETAIL[kind];
|
|
271
|
+
if (!detail) throw new Error(`deliveryGraphVocabulary: no detail for wait probe kind '${kind}' (drift — extend WAIT_PROBE_DETAIL)`);
|
|
272
|
+
return { kind, ...detail };
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
adr: "ADR 0005 — agent-authored delivery graphs",
|
|
277
|
+
summary:
|
|
278
|
+
"A delivery graph is a JSON DAG `{ name?, nodes[], edges[] }` an agent authors as DATA (never BPMN/code — " +
|
|
279
|
+
"the closed node vocabulary is the trust boundary). The agent surface ends at propose → compile → stage; " +
|
|
280
|
+
"DISPATCH is an operator-only cockpit action. This tool surfaces the closed vocabulary + the non-obvious " +
|
|
281
|
+
"wait/poll/fact-threading semantics so they are discoverable, not source-only.",
|
|
282
|
+
nodeKinds,
|
|
283
|
+
factTypes: [...DELIVERY_FACT_TYPES],
|
|
284
|
+
guardScalarTypes: [...DELIVERY_GUARD_SCALAR_TYPES],
|
|
285
|
+
waitProbeKinds,
|
|
286
|
+
connectorTargets: [...REAL_CONNECTOR_TARGETS, FORWARD_DECLARED_ENTRY],
|
|
287
|
+
onTimeout: ON_TIMEOUTS.map((value) => {
|
|
288
|
+
const meaning = ON_TIMEOUT_DETAIL[value];
|
|
289
|
+
if (!meaning) throw new Error(`deliveryGraphVocabulary: no meaning for onTimeout '${value}' (drift — extend ON_TIMEOUT_DETAIL)`);
|
|
290
|
+
return { value, meaning };
|
|
291
|
+
}),
|
|
292
|
+
pollBudget: {
|
|
293
|
+
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
|
294
|
+
defaultTimeoutIso: DEFAULT_READINESS_TIMEOUT,
|
|
295
|
+
defaultEveryMs: DEFAULT_EVERY_MS,
|
|
296
|
+
rule:
|
|
297
|
+
`POLL-BUDGET TRAP: an omitted \`poll\`/\`poll.timeoutMs\` inherits the built-in default budget of ` +
|
|
298
|
+
`${DEFAULT_READINESS_TIMEOUT} (${DEFAULT_TIMEOUT_MS} ms), re-probing every ${DEFAULT_EVERY_MS} ms. ` +
|
|
299
|
+
`That default is right for "is the package published yet" but badly wrong for \`wait[pr, merged]\` / ` +
|
|
300
|
+
`\`wait[epic]\`, which routinely wait hours or days — such a gate would escalate after 30 minutes for ` +
|
|
301
|
+
`no visible reason (neither compile nor preview surfaces the effective bound). ALWAYS set a realistic ` +
|
|
302
|
+
`\`poll.timeoutMs\` explicitly on any merge or epic gate.`,
|
|
303
|
+
},
|
|
304
|
+
factThreading: {
|
|
305
|
+
rule:
|
|
306
|
+
"A node's emitted `fact` is carried to a consumer ONLY by an EDGE. An edge `from` is either a bare " +
|
|
307
|
+
"`<nodeId>` (the node's completion fact) or a qualified `<nodeId>.<fact>` referencing a declared `emits`. " +
|
|
308
|
+
"A node that references a fact (e.g. a connector/`wait[pr]` late-binding `open.pr`) MUST have an incoming " +
|
|
309
|
+
"edge threading that fact from every producer — an unthreaded reference is rejected (`unbound-pr`).",
|
|
310
|
+
details: [
|
|
311
|
+
"The referenced fact must be declared in the producer's `emits[]` with the right `type` (a `pr` reference must be `pr`-typed).",
|
|
312
|
+
"A connector `payload` may OMIT `pr` to auto-bind the SINGLE incoming `pr` fact; with two `pr` facts flowing in you must name one.",
|
|
313
|
+
"Only scalar facts (`string`/`number`/`boolean`) may be referenced by an edge `when` guard; `artifact`/`version`/`url`/`pr` are not guardable.",
|
|
314
|
+
"The whole edge set must be a DAG; a self-edge or cycle is rejected.",
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
guideSection: "docs/agent-guide.md §9 (Author and run a delivery graph)",
|
|
318
|
+
};
|
|
319
|
+
}
|
package/app/readiness.ts
CHANGED
|
@@ -56,11 +56,16 @@ export type OnTimeout = "escalate" | "fail" | "continue";
|
|
|
56
56
|
/** Backoff policy between poll attempts. */
|
|
57
57
|
export type Backoff = "fixed" | "exponential";
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
// The CLOSED per-field vocabularies `parseProbe` validates against — the single runtime source of
|
|
60
|
+
// truth for "which probe kinds / onTimeout options / conditions are legal". Exported so the
|
|
61
|
+
// delivery-graph vocabulary surface (`app/deliveryGraphVocabulary.ts`, S3/#609) DERIVES its
|
|
62
|
+
// structured description from these exact arrays and a drift test fails the build if a kind/condition
|
|
63
|
+
// is added here without a matching vocabulary entry (AGENTS.md: no drift surfaces).
|
|
64
|
+
export const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"];
|
|
65
|
+
export const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
|
|
66
|
+
export const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
|
|
67
|
+
export const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
|
|
68
|
+
export const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"];
|
|
64
69
|
|
|
65
70
|
/** The per-kind readiness predicate. Every field is optional; each kind reads only the ones it
|
|
66
71
|
* understands and applies a sensible default when a field is absent (see the matchers below). */
|
package/docs/agent-guide.md
CHANGED
|
@@ -412,6 +412,15 @@ PR #202 → a human does a manual OTP publish → PR #303 consumes the just-publ
|
|
|
412
412
|
**delivery graph** ([ADR 0005](https://github.com/nanobpm/nano-workforce/blob/main/docs/adr/0005-agent-authored-delivery-graphs.md))
|
|
413
413
|
lets you compose exactly that as **data** and hand it to a generic runner.
|
|
414
414
|
|
|
415
|
+
> **Discover the vocabulary from the surface.** Everything this section describes — the four
|
|
416
|
+
> node kinds and their body contracts, every `wait` probe kind and **what it observes**, the
|
|
417
|
+
> real-vs-stub connector targets, the `onTimeout` options, the poll-budget trap, and the
|
|
418
|
+
> fact-threading rules — is also available as **structured JSON** from the read tool
|
|
419
|
+
> **`getDeliveryGraphVocabulary`** (`GET __BASE__/delivery-graph/vocabulary`). It is derived
|
|
420
|
+
> from the implementing code (a drift test fails the build if the two disagree), so prose and
|
|
421
|
+
> data can never drift. Fetch it to author against the live vocabulary; this section is the
|
|
422
|
+
> narrative companion.
|
|
423
|
+
|
|
415
424
|
You author the graph as **JSON — never BPMN or code** (Decision 1: the agent must never
|
|
416
425
|
author the executable artifact; the closed node vocabulary is the trust boundary). Your
|
|
417
426
|
surface ends at **propose → compile → stage**: a single `compile` door validates the JSON,
|
|
@@ -741,3 +750,9 @@ Semantics:
|
|
|
741
750
|
B before its dependency merged. Size `timeoutMs` to how long the epic realistically takes.
|
|
742
751
|
- On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
|
|
743
752
|
output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
|
|
753
|
+
- **It also gates a single-PR *feature run*, not just a plan-fanout epic.** The gate resolves the
|
|
754
|
+
lineage thread whose **`rootRequestKey`** matches `target` **regardless of the thread's `kind`**
|
|
755
|
+
(`feature` | `epic` | `pr` | `delivery`), and `app/lineage.ts` lands a *feature* thread on
|
|
756
|
+
`stage:"merged"` once its PR merges. So `wait[epic]` targeting a feature/epic **root issue**
|
|
757
|
+
observes that thread's aggregate frontier and releases on `stage:"merged" && active:false` either
|
|
758
|
+
way — see `getDeliveryGraphVocabulary` (the `epic` probe entry) for the structured contract.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Delivery-graph read-after-write regression net (epic #605 slice S2, issue #608).
|
|
2
|
+
//
|
|
3
|
+
// PINS the acceptance guarantee: immediately after `compileDeliveryGraph` returns a staged `digest`,
|
|
4
|
+
// `listStagedProposals` returns THAT digest — with NO intervening delay. In the evidence session
|
|
5
|
+
// (issue #608) a `compileDeliveryGraph` that returned `status:"ready"` with `digest:"ca8fb90a1b0c"`
|
|
6
|
+
// was followed by a `listStagedProposals` that answered `{"count":0,"proposals":[]}` — the read and
|
|
7
|
+
// the write had disagreed, so an agent could not confirm its own staging. The fix serves both from a
|
|
8
|
+
// single source of truth: the read (`listStagedProposals` / `getStagedProposal`, unified through the
|
|
9
|
+
// one `isLiveStaged` predicate) queries the SAME `delivery_graph_proposals` store, in the SAME scope,
|
|
10
|
+
// that the compile/stage write commits to — no read-model or cache in between.
|
|
11
|
+
//
|
|
12
|
+
// This case is RUNNABLE VIA THE SLICE S1 HARNESS (`e2e/support/mcp-harness.ts`): it imports
|
|
13
|
+
// `bootMcpHarness` and drives the REAL runtime-served `/app/mcp` surface over the client handshake —
|
|
14
|
+
// the exact transport an agent uses — so a regression that reintroduces the disagreement (a lagging
|
|
15
|
+
// projection, a divergent read filter, a supersede that drops the just-written row) fails the build.
|
|
16
|
+
// It does NOT re-implement the handshake (see the harness module header's EXTENSION SEAM).
|
|
17
|
+
//
|
|
18
|
+
// Run with `npm run e2e`.
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { after, before, describe, test } from "node:test";
|
|
21
|
+
import { assertObjectBodyAccepted, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
|
|
22
|
+
|
|
23
|
+
/** A minimal side-effecting graph — `compileDeliveryGraph` STAGES it (unlike the pure `previewDelivery
|
|
24
|
+
* Graph`), which is exactly the write whose visibility we verify. Two agent nodes → two side effects. */
|
|
25
|
+
const STAGES_A_PROPOSAL = {
|
|
26
|
+
name: "S2 read-after-write A",
|
|
27
|
+
nodes: [
|
|
28
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
|
|
29
|
+
{ id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
|
|
30
|
+
],
|
|
31
|
+
edges: [{ from: "open", to: "cut" }],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A dedicated logical key for the supersede test, distinct from the first test's key so the case is
|
|
35
|
+
* SELF-CONTAINED — it stages BOTH its own V1 and V2 in-test and asserts per-logical-key, rather than
|
|
36
|
+
* depending on an earlier test having staged a predecessor. */
|
|
37
|
+
const SUPERSEDE_KEY = "S2 supersede key";
|
|
38
|
+
|
|
39
|
+
/** V1 for the supersede test — the predecessor that staging V2 must retire. */
|
|
40
|
+
const SUPERSEDE_V1 = {
|
|
41
|
+
name: SUPERSEDE_KEY,
|
|
42
|
+
nodes: [
|
|
43
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
|
|
44
|
+
{ id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
|
|
45
|
+
],
|
|
46
|
+
edges: [{ from: "open", to: "cut" }],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** V2 — a STRUCTURALLY different graph (an extra node → different compiled BPMN → different content
|
|
50
|
+
* digest) sharing the SAME `name` (logical key) as V1, so staging it supersedes V1. The read after it
|
|
51
|
+
* must show the SECOND digest (the one the compile just returned), never the superseded first. */
|
|
52
|
+
const SUPERSEDE_V2 = {
|
|
53
|
+
name: SUPERSEDE_KEY,
|
|
54
|
+
nodes: [
|
|
55
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
|
|
56
|
+
{ id: "notes", kind: "agent", agent: { jobType: "senior:demo", prompt: "draft the release notes" } },
|
|
57
|
+
{ id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
|
|
58
|
+
],
|
|
59
|
+
edges: [
|
|
60
|
+
{ from: "open", to: "notes" },
|
|
61
|
+
{ from: "notes", to: "cut" },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
interface StagedRow { digest: string; title: string | null }
|
|
66
|
+
interface ListBody { count: number; proposals: StagedRow[] }
|
|
67
|
+
|
|
68
|
+
/** Compile a graph over MCP and return the staged digest the door reports (asserting it staged). */
|
|
69
|
+
async function compileAndStage(h: McpHarness, graph: unknown): Promise<string> {
|
|
70
|
+
const res = await h.callTool("compileDeliveryGraph", { body: graph });
|
|
71
|
+
assertObjectBodyAccepted(res, "compileDeliveryGraph"); // the object body was NOT stringified
|
|
72
|
+
assert.ok(!res.isError, `compileDeliveryGraph must stage a valid graph: ${res.text}`);
|
|
73
|
+
const json = res.json as { status?: string; digest?: string } | undefined;
|
|
74
|
+
assert.equal(json?.status, "ready", `compileDeliveryGraph must report status:"ready": ${res.text}`);
|
|
75
|
+
assert.ok(typeof json?.digest === "string" && json.digest.length > 0, `a staged digest is required: ${res.text}`);
|
|
76
|
+
return json.digest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The current live staged list, read over the SAME MCP surface. */
|
|
80
|
+
async function listStaged(h: McpHarness): Promise<ListBody> {
|
|
81
|
+
const res = await h.callTool("listStagedProposals", {});
|
|
82
|
+
assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
|
|
83
|
+
const json = res.json as ListBody | undefined;
|
|
84
|
+
assert.ok(json && Array.isArray(json.proposals), `listStagedProposals must return a proposals array: ${res.text}`);
|
|
85
|
+
return json;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("S2 — listStagedProposals read-after-write is trustworthy (#608)", () => {
|
|
89
|
+
let h: McpHarness;
|
|
90
|
+
before(async () => { h = await bootMcpHarness(); });
|
|
91
|
+
after(async () => { await h.stop(); });
|
|
92
|
+
|
|
93
|
+
test("a digest compileDeliveryGraph just returned is listed on the very next call — no delay", async () => {
|
|
94
|
+
const digest = await compileAndStage(h, STAGES_A_PROPOSAL);
|
|
95
|
+
// The immediate read — no sleep, no retry, no poll — must show the write.
|
|
96
|
+
const list = await listStaged(h);
|
|
97
|
+
const digests = list.proposals.map((p) => p.digest);
|
|
98
|
+
assert.ok(
|
|
99
|
+
digests.includes(digest),
|
|
100
|
+
`the digest ${digest} compileDeliveryGraph just returned must appear in listStagedProposals ` +
|
|
101
|
+
`immediately (got ${JSON.stringify(digests)})`,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("supersede keeps read-after-write honest: the read shows the LATEST returned digest, not the superseded one", async () => {
|
|
106
|
+
// SELF-CONTAINED: stage V1 then V2 for the SAME logical key inside this test — V2 supersedes V1. The
|
|
107
|
+
// read must reflect the digest THIS compile returned (the live one) and the superseded predecessor
|
|
108
|
+
// must be gone, with no dependence on any other test having staged first.
|
|
109
|
+
const digestV1 = await compileAndStage(h, SUPERSEDE_V1);
|
|
110
|
+
const digestV2 = await compileAndStage(h, SUPERSEDE_V2);
|
|
111
|
+
assert.notEqual(digestV1, digestV2, "V1 and V2 must differ in content so V2 genuinely supersedes V1");
|
|
112
|
+
const list = await listStaged(h);
|
|
113
|
+
const digests = list.proposals.map((p) => p.digest);
|
|
114
|
+
assert.ok(
|
|
115
|
+
digests.includes(digestV2),
|
|
116
|
+
`after superseding, listStagedProposals must show the latest digest ${digestV2} (got ${JSON.stringify(digests)})`,
|
|
117
|
+
);
|
|
118
|
+
assert.ok(
|
|
119
|
+
!digests.includes(digestV1),
|
|
120
|
+
`the superseded predecessor digest ${digestV1} must be gone from listStagedProposals (got ${JSON.stringify(digests)})`,
|
|
121
|
+
);
|
|
122
|
+
// Exactly one live proposal FOR THIS logical key — filter by title so the assertion is per-logical-key
|
|
123
|
+
// and does not couple to how many other proposals exist in the shared store.
|
|
124
|
+
const forLogicalKey = list.proposals.filter((p) => p.title === SUPERSEDE_KEY);
|
|
125
|
+
assert.equal(
|
|
126
|
+
forLogicalKey.length,
|
|
127
|
+
1,
|
|
128
|
+
`exactly one live staged proposal must remain for the logical key ${JSON.stringify(SUPERSEDE_KEY)} (got ${JSON.stringify(forLogicalKey.map((p) => p.digest))})`,
|
|
129
|
+
);
|
|
130
|
+
assert.equal(forLogicalKey[0]?.digest, digestV2, "the sole remaining live proposal must be the latest digest");
|
|
131
|
+
});
|
|
132
|
+
});
|
package/openapi.yaml
CHANGED
|
@@ -4029,11 +4029,25 @@ paths:
|
|
|
4029
4029
|
operationId: listStagedProposals
|
|
4030
4030
|
summary: List the LIVE staged delivery-graph proposals awaiting dispatch (issue #511), newest first.
|
|
4031
4031
|
description: >-
|
|
4032
|
-
The read behind the staged-proposals App-View
|
|
4033
|
-
not aged out of its TTL, newest first, projected to the
|
|
4034
|
-
`graph`/`preview` payloads are omitted — the App-View
|
|
4035
|
-
|
|
4036
|
-
|
|
4032
|
+
The read behind the staged-proposals App-View AND the MCP `listStagedProposals` tool: every
|
|
4033
|
+
`staged` delivery-graph proposal that has not aged out of its TTL, newest first, projected to the
|
|
4034
|
+
Preview-DI + Dispatch metadata (the `graph`/`preview` payloads are omitted — the App-View
|
|
4035
|
+
recompiles by `digest` for the DI preview). Mirrors the
|
|
4036
|
+
`previewProposalBpmn`/`dispatchDeliveryGraph` freshness guard so an expired-but-not-yet-swept row
|
|
4037
|
+
is never listed. Read-only.
|
|
4038
|
+
|
|
4039
|
+
|
|
4040
|
+
FRESHNESS / CONSISTENCY (issue #608). This read is READ-AFTER-WRITE consistent with
|
|
4041
|
+
`compileDeliveryGraph`: it is served by a fresh query against the SAME durable
|
|
4042
|
+
`delivery_graph_proposals` store, in the SAME scope, that the compile/stage write commits to (the
|
|
4043
|
+
app's single default data source) — there is no projection, read-model, or cache between the
|
|
4044
|
+
write and this read. So a `digest` that `compileDeliveryGraph` just returned as `status: "ready"`
|
|
4045
|
+
is guaranteed to appear here on the very next call, with NO intervening delay and no polling
|
|
4046
|
+
needed (the row is committed before the compile door responds). The one caveat is scope: the MCP
|
|
4047
|
+
endpoint and the cockpit must resolve the SAME app deployment — point the MCP client at the same
|
|
4048
|
+
instance/mount whose `reviewUrl` the compile door returned, or the read and the write address
|
|
4049
|
+
different databases and disagree. Returns an empty list only when nothing is genuinely staged
|
|
4050
|
+
(or every staged row has aged out of its TTL).
|
|
4037
4051
|
security:
|
|
4038
4052
|
- hookSecret: []
|
|
4039
4053
|
- {}
|
|
@@ -4050,6 +4064,262 @@ paths:
|
|
|
4050
4064
|
application/json:
|
|
4051
4065
|
schema:
|
|
4052
4066
|
$ref: "#/components/schemas/ErrorBody"
|
|
4067
|
+
/delivery-graph/vocabulary:
|
|
4068
|
+
get:
|
|
4069
|
+
operationId: getDeliveryGraphVocabulary
|
|
4070
|
+
summary: The closed delivery-graph vocabulary + wait-probe semantics as structured JSON (ADR 0005).
|
|
4071
|
+
description: >-
|
|
4072
|
+
Read tool (projected onto the MCP surface like `getAgentInstructions`). Returns the CLOSED
|
|
4073
|
+
delivery-graph vocabulary and the non-obvious wait/poll/fact-threading semantics as structured
|
|
4074
|
+
JSON, so you can discover them from the surface instead of reading source. Covers: the four node
|
|
4075
|
+
kinds (`agent`/`wait`/`human`/`connector`) with their per-kind body contracts; every wait probe
|
|
4076
|
+
kind with its `match` fields and — crucially — WHAT it OBSERVES (e.g. `epic` resolves a lineage
|
|
4077
|
+
thread by `rootRequestKey` REGARDLESS of the thread's kind, so it gates plan-fanout epics AND
|
|
4078
|
+
single-PR feature runs alike, ready on `stage:"merged" && active:false`); which connector targets
|
|
4079
|
+
are real (`converge`/`converge-merge`/`merge-main`) vs. forward-declared stubs; the `onTimeout`
|
|
4080
|
+
options; the poll-budget rule (always set a realistic `poll.timeoutMs` on merge/epic gates — the
|
|
4081
|
+
30-minute default is a trap); and the edge/fact-threading rules (a `node.fact` must be threaded by
|
|
4082
|
+
an edge to every consumer, else `unbound-pr`). Derived from the implementing code (a drift test
|
|
4083
|
+
fails the build if a probe kind / connector target is added without a vocabulary entry). Pure,
|
|
4084
|
+
read-only, idempotent — no side effects. Pairs with `compileDeliveryGraph`/`previewDeliveryGraph`:
|
|
4085
|
+
call this first to learn the vocabulary, then author a `DeliveryGraph` and compile it.
|
|
4086
|
+
security:
|
|
4087
|
+
- hookSecret: []
|
|
4088
|
+
- {}
|
|
4089
|
+
responses:
|
|
4090
|
+
"200":
|
|
4091
|
+
description: The full delivery-graph vocabulary.
|
|
4092
|
+
content:
|
|
4093
|
+
application/json:
|
|
4094
|
+
schema:
|
|
4095
|
+
type: object
|
|
4096
|
+
additionalProperties: false
|
|
4097
|
+
description: The closed delivery-graph vocabulary + wait-probe semantics, derived from the compiler/runner code.
|
|
4098
|
+
required:
|
|
4099
|
+
- adr
|
|
4100
|
+
- summary
|
|
4101
|
+
- nodeKinds
|
|
4102
|
+
- factTypes
|
|
4103
|
+
- guardScalarTypes
|
|
4104
|
+
- waitProbeKinds
|
|
4105
|
+
- connectorTargets
|
|
4106
|
+
- onTimeout
|
|
4107
|
+
- pollBudget
|
|
4108
|
+
- factThreading
|
|
4109
|
+
properties:
|
|
4110
|
+
adr:
|
|
4111
|
+
type: string
|
|
4112
|
+
description: The governing ADR (agent-authored delivery graphs).
|
|
4113
|
+
summary:
|
|
4114
|
+
type: string
|
|
4115
|
+
description: One-paragraph orientation on the graph shape and the propose→compile→stage surface.
|
|
4116
|
+
nodeKinds:
|
|
4117
|
+
type: array
|
|
4118
|
+
description: The closed node-kind allowlist with each kind's config key and body contract.
|
|
4119
|
+
items:
|
|
4120
|
+
type: object
|
|
4121
|
+
additionalProperties: false
|
|
4122
|
+
required: [kind, configKey, requiredFields, optionalFields, sideEffecting, mayEmit, summary]
|
|
4123
|
+
properties:
|
|
4124
|
+
kind:
|
|
4125
|
+
type: string
|
|
4126
|
+
description: The node kind (one of agent | wait | human | connector).
|
|
4127
|
+
configKey:
|
|
4128
|
+
type: string
|
|
4129
|
+
description: The per-kind config object key the node must carry.
|
|
4130
|
+
requiredFields:
|
|
4131
|
+
type: array
|
|
4132
|
+
items: { type: string }
|
|
4133
|
+
description: Required non-empty fields inside the per-kind config.
|
|
4134
|
+
optionalFields:
|
|
4135
|
+
type: array
|
|
4136
|
+
items: { type: string }
|
|
4137
|
+
description: Optional fields inside the per-kind config.
|
|
4138
|
+
sideEffecting:
|
|
4139
|
+
type: boolean
|
|
4140
|
+
description: Whether the node performs a side effect (agent/connector) vs. read-only (wait/human).
|
|
4141
|
+
mayEmit:
|
|
4142
|
+
type: boolean
|
|
4143
|
+
description: Whether the node may declare typed emits.
|
|
4144
|
+
summary:
|
|
4145
|
+
type: string
|
|
4146
|
+
description: The body contract / semantics of the kind.
|
|
4147
|
+
factTypes:
|
|
4148
|
+
type: array
|
|
4149
|
+
items: { type: string }
|
|
4150
|
+
description: The closed emitted-fact type allowlist.
|
|
4151
|
+
guardScalarTypes:
|
|
4152
|
+
type: array
|
|
4153
|
+
items: { type: string }
|
|
4154
|
+
description: The scalar fact types an edge `when` guard may reference.
|
|
4155
|
+
waitProbeKinds:
|
|
4156
|
+
type: array
|
|
4157
|
+
description: Every wait probe kind, its match fields, and what it observes / when it is ready.
|
|
4158
|
+
items:
|
|
4159
|
+
type: object
|
|
4160
|
+
additionalProperties: false
|
|
4161
|
+
required: [kind, target, matchFields, observes, ready]
|
|
4162
|
+
properties:
|
|
4163
|
+
kind:
|
|
4164
|
+
type: string
|
|
4165
|
+
description: The probe kind (http | command | npm | github-check | capability | pr | epic).
|
|
4166
|
+
target:
|
|
4167
|
+
type: string
|
|
4168
|
+
description: What the probe's `target` names.
|
|
4169
|
+
matchFields:
|
|
4170
|
+
type: array
|
|
4171
|
+
items: { type: string }
|
|
4172
|
+
description: The `match` fields this kind reads.
|
|
4173
|
+
conditions:
|
|
4174
|
+
type: array
|
|
4175
|
+
items: { type: string }
|
|
4176
|
+
description: The closed condition set for pr/epic kinds (else absent).
|
|
4177
|
+
observes:
|
|
4178
|
+
type: string
|
|
4179
|
+
description: The read that decides readiness (what the probe actually observes).
|
|
4180
|
+
ready:
|
|
4181
|
+
type: string
|
|
4182
|
+
description: The condition under which the probe reports ready.
|
|
4183
|
+
binds:
|
|
4184
|
+
type: array
|
|
4185
|
+
items: { type: string }
|
|
4186
|
+
description: Output facts the probe binds on a ready match.
|
|
4187
|
+
connectorTargets:
|
|
4188
|
+
type: array
|
|
4189
|
+
description: Which connector targets are real (converge-enrollment) vs. forward-declared stubs.
|
|
4190
|
+
items:
|
|
4191
|
+
type: object
|
|
4192
|
+
additionalProperties: false
|
|
4193
|
+
required: [target, status, summary]
|
|
4194
|
+
properties:
|
|
4195
|
+
target:
|
|
4196
|
+
type: string
|
|
4197
|
+
description: The connector target literal (or a sentinel for any other target).
|
|
4198
|
+
status:
|
|
4199
|
+
type: string
|
|
4200
|
+
enum: [real, forward-declared]
|
|
4201
|
+
description: real ⇒ dispatches a real side effect; forward-declared ⇒ a no-op stub.
|
|
4202
|
+
convergeOnlyDefault:
|
|
4203
|
+
type: boolean
|
|
4204
|
+
description: The default `convergeOnly` for a real converge target.
|
|
4205
|
+
summary:
|
|
4206
|
+
type: string
|
|
4207
|
+
description: What the target does.
|
|
4208
|
+
onTimeout:
|
|
4209
|
+
type: array
|
|
4210
|
+
description: The `onTimeout` options for a bounded wait and what each does.
|
|
4211
|
+
items:
|
|
4212
|
+
type: object
|
|
4213
|
+
additionalProperties: false
|
|
4214
|
+
required: [value, meaning]
|
|
4215
|
+
properties:
|
|
4216
|
+
value: { type: string }
|
|
4217
|
+
meaning: { type: string }
|
|
4218
|
+
pollBudget:
|
|
4219
|
+
type: object
|
|
4220
|
+
additionalProperties: false
|
|
4221
|
+
required: [defaultTimeoutMs, defaultTimeoutIso, defaultEveryMs, rule]
|
|
4222
|
+
description: The poll-budget defaults and the "always set poll.timeoutMs on merge/epic gates" rule.
|
|
4223
|
+
properties:
|
|
4224
|
+
defaultTimeoutMs: { type: number }
|
|
4225
|
+
defaultTimeoutIso: { type: string }
|
|
4226
|
+
defaultEveryMs: { type: number }
|
|
4227
|
+
rule: { type: string }
|
|
4228
|
+
factThreading:
|
|
4229
|
+
type: object
|
|
4230
|
+
additionalProperties: false
|
|
4231
|
+
required: [rule, details]
|
|
4232
|
+
description: The edge/fact-threading rules — a node.fact reaches a consumer only via an edge.
|
|
4233
|
+
properties:
|
|
4234
|
+
rule: { type: string }
|
|
4235
|
+
details:
|
|
4236
|
+
type: array
|
|
4237
|
+
items: { type: string }
|
|
4238
|
+
guideSection:
|
|
4239
|
+
type: string
|
|
4240
|
+
description: The operator-guide section this data mirrors (docs/agent-guide.md §9).
|
|
4241
|
+
example:
|
|
4242
|
+
adr: "ADR 0005 — agent-authored delivery graphs"
|
|
4243
|
+
summary: "A delivery graph is a JSON DAG an agent authors as DATA; the surface ends at propose → compile → stage."
|
|
4244
|
+
nodeKinds:
|
|
4245
|
+
- kind: agent
|
|
4246
|
+
configKey: agent
|
|
4247
|
+
requiredFields: [jobType]
|
|
4248
|
+
optionalFields: [prompt, converge, merge]
|
|
4249
|
+
sideEffecting: true
|
|
4250
|
+
mayEmit: true
|
|
4251
|
+
summary: "A worker runs an agent job type; an agent that opens a PR emits it as a `pr` fact."
|
|
4252
|
+
- kind: wait
|
|
4253
|
+
configKey: wait
|
|
4254
|
+
requiredFields: [kind, target]
|
|
4255
|
+
optionalFields: [match, poll, onTimeout, credentialEnv]
|
|
4256
|
+
sideEffecting: false
|
|
4257
|
+
mayEmit: true
|
|
4258
|
+
summary: "A durable, bounded, read-only readiness probe (a ReadinessProbe verbatim)."
|
|
4259
|
+
- kind: connector
|
|
4260
|
+
configKey: connector
|
|
4261
|
+
requiredFields: [target]
|
|
4262
|
+
optionalFields: [dedupeKey, payload]
|
|
4263
|
+
sideEffecting: true
|
|
4264
|
+
mayEmit: true
|
|
4265
|
+
summary: "An automated outbound action; payload for a converge target is { pr, convergeOnly?, dependsOn? }."
|
|
4266
|
+
factTypes: [string, number, boolean, artifact, version, url, pr]
|
|
4267
|
+
guardScalarTypes: [string, number, boolean]
|
|
4268
|
+
waitProbeKinds:
|
|
4269
|
+
- kind: pr
|
|
4270
|
+
target: "an owner/repo#N PR (or a <node>.pr fact reference)"
|
|
4271
|
+
matchFields: [prState]
|
|
4272
|
+
conditions: [ready, merged, mergeable, checks-green]
|
|
4273
|
+
observes: "the live GitHub state of one in-flight PR; only OBSERVES, level-triggered."
|
|
4274
|
+
ready: "the PR reaches match.prState (default merged)."
|
|
4275
|
+
binds: [mergedSha]
|
|
4276
|
+
- kind: epic
|
|
4277
|
+
target: "the epic's durable planKey (owner/repo#NN, the epic issue)"
|
|
4278
|
+
matchFields: [epicState]
|
|
4279
|
+
conditions: [merged, done]
|
|
4280
|
+
observes: "the app's lineage read-model, resolved by rootRequestKey REGARDLESS of thread kind (feature | epic | pr | delivery) — so it gates a single-PR FEATURE RUN just as well as a plan-fanout epic."
|
|
4281
|
+
ready: 'the lineage thread reaches stage:"merged" && active:false (every opened slice/PR landed).'
|
|
4282
|
+
binds: [prCount]
|
|
4283
|
+
connectorTargets:
|
|
4284
|
+
- target: converge
|
|
4285
|
+
status: real
|
|
4286
|
+
convergeOnlyDefault: true
|
|
4287
|
+
summary: "Converge-only: drive review convergence and stop at converged."
|
|
4288
|
+
- target: converge-merge
|
|
4289
|
+
status: real
|
|
4290
|
+
convergeOnlyDefault: false
|
|
4291
|
+
summary: "Unit-level land: converge AND merge onto the PR's own base branch."
|
|
4292
|
+
- target: merge-main
|
|
4293
|
+
status: real
|
|
4294
|
+
convergeOnlyDefault: false
|
|
4295
|
+
summary: "Graph-level top-level land onto main (two-level merge)."
|
|
4296
|
+
- target: "<any other target>"
|
|
4297
|
+
status: forward-declared
|
|
4298
|
+
summary: "Forward-declared stub — returns a deterministic acknowledgement, fires no real I/O."
|
|
4299
|
+
onTimeout:
|
|
4300
|
+
- value: escalate
|
|
4301
|
+
meaning: "park a human escalation when the bounded wait elapses."
|
|
4302
|
+
- value: fail
|
|
4303
|
+
meaning: "terminate the gate as failed (NOT yet supported on a wait node — rejected by the compiler)."
|
|
4304
|
+
- value: continue
|
|
4305
|
+
meaning: "proceed as if ready when the wait elapses (a soft gate)."
|
|
4306
|
+
pollBudget:
|
|
4307
|
+
defaultTimeoutMs: 1800000
|
|
4308
|
+
defaultTimeoutIso: PT30M
|
|
4309
|
+
defaultEveryMs: 15000
|
|
4310
|
+
rule: "An omitted poll.timeoutMs inherits the 30-minute default — a trap for wait[pr, merged]/wait[epic] which wait hours/days. Always set poll.timeoutMs explicitly on a merge/epic gate."
|
|
4311
|
+
factThreading:
|
|
4312
|
+
rule: "A node's emitted fact reaches a consumer ONLY via an edge (<nodeId>.<fact>); an unthreaded reference is rejected (unbound-pr)."
|
|
4313
|
+
details:
|
|
4314
|
+
- "The referenced fact must be declared in the producer's emits[] with the right type."
|
|
4315
|
+
- "A connector payload may omit pr to auto-bind the single incoming pr fact."
|
|
4316
|
+
guideSection: "docs/agent-guide.md §9 (Author and run a delivery graph)"
|
|
4317
|
+
"401":
|
|
4318
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
4319
|
+
content:
|
|
4320
|
+
application/json:
|
|
4321
|
+
schema:
|
|
4322
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
4053
4323
|
/actions/delivery-graph/library/save:
|
|
4054
4324
|
post:
|
|
4055
4325
|
operationId: saveToLibrary
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// GET /app/api/delivery-graph/vocabulary → operationId `getDeliveryGraphVocabulary` (epic
|
|
2
|
+
// nano-workforce#605, S3/#609). A read tool — projected onto the MCP surface like
|
|
3
|
+
// `getAgentInstructions` — that returns the closed delivery-graph vocabulary + wait-probe semantics
|
|
4
|
+
// as STRUCTURED JSON, so an agent can discover the node/probe/connector vocabulary and the non-obvious
|
|
5
|
+
// wait/poll/fact-threading rules from the surface instead of reading source (ADR 0005).
|
|
6
|
+
//
|
|
7
|
+
// The payload is derived from the implementing code (`app/deliveryGraphVocabulary.ts`) — every closed
|
|
8
|
+
// set is imported from its owning module, and a drift test fails the build if a probe kind / connector
|
|
9
|
+
// target lands in the compiler without a vocabulary entry. Cross-linked from docs/agent-guide.md §9.
|
|
10
|
+
//
|
|
11
|
+
// Read-only. The optional shared-secret guard mirrors /agent and /version: enforced HERE only when
|
|
12
|
+
// NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
|
|
13
|
+
import { deliveryGraphVocabulary } from "../app/deliveryGraphVocabulary.ts";
|
|
14
|
+
import { envVar } from "../app/version.ts";
|
|
15
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
16
|
+
|
|
17
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
18
|
+
|
|
19
|
+
export default defineOperation("getDeliveryGraphVocabulary", ({ req }, app) => {
|
|
20
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
21
|
+
app.log.warn("getDeliveryGraphVocabulary rejected: missing/invalid shared secret");
|
|
22
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
23
|
+
}
|
|
24
|
+
return { status: 200, body: deliveryGraphVocabulary() };
|
|
25
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.160.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",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// test/deliveryGraphVocabulary-mcp.test.ts — S3/#609 surface guard.
|
|
2
|
+
//
|
|
3
|
+
// Verifies the `getDeliveryGraphVocabulary` READ tool is actually VISIBLE to agents over MCP (the
|
|
4
|
+
// Urban runtime projects `openapi.yaml` into MCP tools, ADR 0067 — zero MCP server code in nwf) and
|
|
5
|
+
// that its operation conforms to S0's self-contained convention: a `$ref`-free `200` response schema
|
|
6
|
+
// with an explicit `type: object` and a worked `example`. Drives the REAL projector (`collectOperations`
|
|
7
|
+
// from `@nanobpm/urban/toolkit`) over the checked-in spec, exactly like `test/mcp-tool-schemas.test.ts`,
|
|
8
|
+
// so a regression (the op excluded, or a re-leaked `$ref`/dropped example) fails the build.
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { collectOperations, parseSpec } from "@nanobpm/urban/toolkit";
|
|
12
|
+
import { parse as parseYaml } from "yaml";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { deliveryGraphVocabulary } from "../app/deliveryGraphVocabulary.ts";
|
|
15
|
+
|
|
16
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
17
|
+
const SPEC_TEXT = readFileSync(`${ROOT}openapi.yaml`, "utf8");
|
|
18
|
+
const SPEC = parseSpec(SPEC_TEXT);
|
|
19
|
+
const OP_ID = "getDeliveryGraphVocabulary";
|
|
20
|
+
|
|
21
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
22
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
23
|
+
|
|
24
|
+
/** Every `$ref` reachable in a schema, JSON-path-qualified for the failure message. */
|
|
25
|
+
function findRefs(node: unknown, path: string, out: string[]): void {
|
|
26
|
+
if (Array.isArray(node)) {
|
|
27
|
+
node.forEach((n, i) => findRefs(n, `${path}[${i}]`, out));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!isRecord(node)) return;
|
|
31
|
+
for (const [k, v] of Object.entries(node)) {
|
|
32
|
+
if (k === "$ref" && typeof v === "string") out.push(`${path}.$ref -> ${v}`);
|
|
33
|
+
else findRefs(v, `${path}.${k}`, out);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("getDeliveryGraphVocabulary is projected onto the MCP tool surface (not excluded)", () => {
|
|
38
|
+
const op = collectOperations(SPEC).find((o) => o.operationId === OP_ID);
|
|
39
|
+
assert(op, `${OP_ID} must be a declared operation the projector can see`);
|
|
40
|
+
assert(!op!.mcpExcluded, `${OP_ID} must be visible over MCP (no x-mcp exclusion)`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the getDeliveryGraphVocabulary 200 response schema is $ref-free with an explicit type + example", () => {
|
|
44
|
+
const doc = parseYaml(SPEC_TEXT) as Record<string, any>;
|
|
45
|
+
const schema = doc?.paths?.["/delivery-graph/vocabulary"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema;
|
|
46
|
+
assert(isRecord(schema), "the 200 response must carry an inline application/json schema");
|
|
47
|
+
assert.equal(schema.type, "object", "the response schema must declare an explicit type: object");
|
|
48
|
+
assert("example" in schema, "the response schema must embed a worked example (S0 self-contained convention)");
|
|
49
|
+
const refs: string[] = [];
|
|
50
|
+
findRefs(schema, `${OP_ID}.responses.200`, refs);
|
|
51
|
+
assert(refs.length === 0, `${OP_ID}: 200 response schema leaks $ref(s): ${refs.join(", ")}`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("the served payload matches the response schema's required keys (data ⇄ contract)", () => {
|
|
55
|
+
const doc = parseYaml(SPEC_TEXT) as Record<string, any>;
|
|
56
|
+
const schema = doc.paths["/delivery-graph/vocabulary"].get.responses["200"].content["application/json"].schema;
|
|
57
|
+
const required: string[] = schema.required ?? [];
|
|
58
|
+
const payload = deliveryGraphVocabulary() as Record<string, unknown>;
|
|
59
|
+
for (const key of required) {
|
|
60
|
+
assert(key in payload, `served vocabulary is missing required schema key '${key}'`);
|
|
61
|
+
}
|
|
62
|
+
});
|