@nanobpm/nano-workforce 0.200.1 → 0.200.3
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/deliveryGraph.ts +9 -6
- package/app/deliveryGraphVocabulary.test.ts +79 -0
- package/app/deliveryGraphVocabulary.ts +22 -4
- package/app/github.ts +4 -3
- package/app/mcpToolSurface.ts +7 -1
- package/app/parseIssue.test.ts +87 -0
- package/app/plan.ts +27 -8
- package/app/prParse.ts +15 -2
- package/app/prShapeSingleGrammar.test.ts +154 -0
- package/app/readiness.test.ts +109 -0
- package/app/readiness.ts +44 -18
- package/app/service.test.ts +55 -0
- package/docs/agent-guide.md +29 -6
- package/openapi.yaml +11 -9
- package/operations/getAgentInstructions.test.ts +7 -2
- package/package.json +2 -2
- package/test/deliveryGraphVocabulary-mcp.test.ts +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.200.3](https://github.com/nanobpm/nano-workforce/compare/v0.200.2...v0.200.3) (2026-10-04)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **readiness:** accept a PR URL in the pr probe via the canonical parsePr ([#857](https://github.com/nanobpm/nano-workforce/issues/857)) ([cc194b7](https://github.com/nanobpm/nano-workforce/commit/cc194b74265392a4fbc5c1166e50f8e40068dee5)), closes [/host#N](https://github.com/nanobpm//host/issues/N) [#856](https://github.com/nanobpm/nano-workforce/issues/856) [owner/repo#N](https://github.com/owner/repo/issues/N) [#856](https://github.com/nanobpm/nano-workforce/issues/856) [owner/repo#N](https://github.com/owner/repo/issues/N) [owner/repo#N](https://github.com/owner/repo/issues/N) [owner/github.com#42](https://github.com/owner/github.com/issues/42) [owner/repo#N](https://github.com/owner/repo/issues/N) [owner/github.com#N](https://github.com/owner/github.com/issues/N) [owner/repo#123](https://github.com/owner/repo/issues/123)
|
|
6
|
+
|
|
7
|
+
## [0.200.2](https://github.com/nanobpm/nano-workforce/compare/v0.200.1...v0.200.2) (2026-10-03)
|
|
8
|
+
|
|
9
|
+
### Documentation
|
|
10
|
+
|
|
11
|
+
* **agent-guide:** require repository on every delivery-graph agent node ([#851](https://github.com/nanobpm/nano-workforce/issues/851)) ([d6770ae](https://github.com/nanobpm/nano-workforce/commit/d6770ae2f6acc490e102aecaf0cab17ca5d10119)), closes [#850](https://github.com/nanobpm/nano-workforce/issues/850) [#850](https://github.com/nanobpm/nano-workforce/issues/850) [#505](https://github.com/nanobpm/nano-workforce/issues/505) [#850](https://github.com/nanobpm/nano-workforce/issues/850) [739/#776](https://github.com/739/nano-workforce/issues/776) [#505](https://github.com/nanobpm/nano-workforce/issues/505)
|
|
12
|
+
|
|
1
13
|
## [0.200.1](https://github.com/nanobpm/nano-workforce/compare/v0.200.0...v0.200.1) (2026-10-03)
|
|
2
14
|
|
|
3
15
|
### Performance Improvements
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
|
23
23
|
import { isEnvKey } from "./contracts.ts";
|
|
24
24
|
import { isConvergeTarget } from "./convergeTargets.ts";
|
|
25
25
|
import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
|
|
26
|
-
import { BACKOFFS, hasEmbeddedCredential, hasEmbeddedUrl, hasSchemeRelativeAuthority, isBackoff, isUrlShaped,
|
|
26
|
+
import { BACKOFFS, hasEmbeddedCredential, hasEmbeddedUrl, hasSchemeRelativeAuthority, isBackoff, isUrlShaped, redactEmbeddedCredentialUrl, redactEmbeddedSchemeRelativeUrl, redactEmbeddedUrl, redactString } from "./readiness.ts";
|
|
27
27
|
import { isResolvableRepo } from "./repoEnvelope.ts";
|
|
28
28
|
|
|
29
29
|
/** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
|
|
@@ -163,7 +163,7 @@ export function hasXmlInvalidChars(value: string): boolean {
|
|
|
163
163
|
* scheme-relative `//authority` form ({@link isUrlShaped}), either of which can hide a credential in
|
|
164
164
|
* userinfo/query/fragment (`redactString` redacts both) — is redacted in FULL (userinfo AND
|
|
165
165
|
* `?query`/`#fragment`). But an embedded `//<userinfo>@` credential can also ride AFTER a non-URL
|
|
166
|
-
* prefix in a free-form value that
|
|
166
|
+
* prefix in a free-form value that keeps a meaningful `#<digits>` PR-number handle (any prefix before
|
|
167
167
|
* `#<digits>`, so `prefix //user:pass@host#42`), which the anchored {@link isUrlShaped} check misses.
|
|
168
168
|
* Because a `//<userinfo>@` span is UNAMBIGUOUSLY a credential wherever it sits (an opaque id never
|
|
169
169
|
* contains one), a scheme-relative `//<userinfo>@authority…` run is treated as an embedded URL and
|
|
@@ -188,14 +188,14 @@ export function redactConnectorValue(value: string): string {
|
|
|
188
188
|
const cleaned = stripXmlInvalidChars(value);
|
|
189
189
|
// A whole-value URL gets the full redact (userinfo + query/fragment) — EXCEPT a whole-value opaque
|
|
190
190
|
// scheme-relative PR ref (`//host#42`: no explicit scheme, no `//…@` credential, no `?query`), whose
|
|
191
|
-
// only URL-ish payload is a MEANINGFUL `#<digits>` fragment (a
|
|
191
|
+
// only URL-ish payload is a MEANINGFUL `#<digits>` fragment (a PR-number handle). `isUrlShaped`
|
|
192
192
|
// matches such a ref (scheme-relative `//authority`), so the whole-value branch would `redactString`
|
|
193
193
|
// its `#42` → `#***` — yet the EMBEDDED contract deliberately PRESERVES the same `//host#42` (the
|
|
194
194
|
// embedded redactors leave a userinfo-/query-less `//host#42` untouched). Route it through the embedded
|
|
195
195
|
// path so a whole-value `//host#42` keeps its `#42` exactly like `prefix //host#42`, closing that
|
|
196
196
|
// whole-value/embedded inconsistency (issue #778 review — thread deliveryGraph.ts:198). Only a
|
|
197
|
-
// NUMERIC `#<digits>` fragment is a valid
|
|
198
|
-
// ordinary URL fragment that can hide a secret, so the exception REQUIRES
|
|
197
|
+
// NUMERIC `#<digits>` fragment is a valid PR-number handle — `//host#access-token` is an
|
|
198
|
+
// ordinary URL fragment that can hide a secret, so the exception REQUIRES the `//host#<digits>` shape to match
|
|
199
199
|
// the whole (trimmed) value; a non-numeric fragment falls to the full whole-value redact like any
|
|
200
200
|
// other URL fragment (issue #778 review — thread deliveryGraph.ts:206). An explicit
|
|
201
201
|
// `scheme://host#42`, a `//user:pass@host#…` credential, or a userinfo-less `//host?token=…` query all
|
|
@@ -211,7 +211,10 @@ export function redactConnectorValue(value: string): string {
|
|
|
211
211
|
cleaned.trim().startsWith("//") &&
|
|
212
212
|
!hasEmbeddedCredential(cleaned) &&
|
|
213
213
|
!cleaned.includes("?") &&
|
|
214
|
-
|
|
214
|
+
// A NUMERIC `#<digits>` fragment after a non-empty authority (`//host#42`). Deliberately a local
|
|
215
|
+
// fragment-shape test, not the PR parser: this is a redaction exemption over a free-form value,
|
|
216
|
+
// and `parsePrTarget` now follows the canonical `parsePr` grammar (#856), which has no `//host` form.
|
|
217
|
+
/^\/\/[^#]+#\d+$/.test(cleaned.trim());
|
|
215
218
|
return isUrlShaped(cleaned) && !isOpaqueSchemeRelativePrRef
|
|
216
219
|
? redactString(cleaned)
|
|
217
220
|
: redactEmbeddedSchemeRelativeUrl(redactEmbeddedCredentialUrl(redactEmbeddedUrl(cleaned)));
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// key sets are byte-identical to the closed sets in `app/deliveryGraph.ts` / `app/readiness.ts` /
|
|
7
7
|
// `app/convergeTargets.ts` (AGENTS.md — "no drift surfaces").
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
9
10
|
import { test } from "node:test";
|
|
11
|
+
import { parse as parseYaml } from "yaml";
|
|
10
12
|
import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
|
|
11
13
|
import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
|
|
12
14
|
import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
|
|
@@ -100,3 +102,80 @@ test("fact-threading rule names the unbound-pr rejection", () => {
|
|
|
100
102
|
const vocab = deliveryGraphVocabulary();
|
|
101
103
|
assert.match(vocab.factThreading.rule, /unbound-pr/);
|
|
102
104
|
});
|
|
105
|
+
|
|
106
|
+
// Resolve a node kind's per-kind CONFIG sub-schema (the authoritative OpenAPI contract for that
|
|
107
|
+
// kind's body) from the parsed spec. `DeliveryNode<Kind>` is `allOf: [DeliveryNodeCommon, { properties:
|
|
108
|
+
// { <configKey>: {…} } }]`, so find the allOf member carrying the `<configKey>` sub-schema and return
|
|
109
|
+
// that member's `<configKey>` VALUE. For most kinds that value is the inline config object; for the
|
|
110
|
+
// `wait` kind it is the `{ $ref: "#/components/schemas/ReadinessProbe" }` object (the config is
|
|
111
|
+
// referenced, not inlined), which this helper returns AS-IS — the caller follows the reference itself.
|
|
112
|
+
// Returns null only when the kind's node schema has NO inline `<configKey>` member at all (which the
|
|
113
|
+
// caller's assert.ok rejects), never for the `wait` kind.
|
|
114
|
+
function nodeConfigSchema(
|
|
115
|
+
spec: Record<string, any>,
|
|
116
|
+
nodeSchemaName: string,
|
|
117
|
+
configKey: string,
|
|
118
|
+
): Record<string, any> | null {
|
|
119
|
+
const schema = spec?.components?.schemas?.[nodeSchemaName];
|
|
120
|
+
if (!schema) return null;
|
|
121
|
+
const sub = (schema.allOf as Array<Record<string, any>> | undefined)?.map((m) => m?.properties?.[configKey]).find(Boolean);
|
|
122
|
+
return sub ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Derive the (sorted) required + optional field sets from a config sub-schema's own `required`/`properties`. */
|
|
126
|
+
function fieldSets(configSchema: Record<string, any>): { required: string[]; optional: string[] } {
|
|
127
|
+
const required = [...((configSchema.required as string[] | undefined) ?? [])].sort();
|
|
128
|
+
const optional = Object.keys((configSchema.properties as Record<string, unknown>) ?? {})
|
|
129
|
+
.filter((k) => !required.includes(k))
|
|
130
|
+
.sort();
|
|
131
|
+
return { required, optional };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("every node-kind entry's field sets are DERIVED from its OpenAPI DeliveryNode<Kind>.<configKey> contract (#739/#850)", () => {
|
|
135
|
+
// The #850 failure mode is drift between a `DeliveryNode<Kind>.<configKey>` (the authoritative
|
|
136
|
+
// OpenAPI contract) and this vocabulary surface. A literal field list duplicated here would NOT
|
|
137
|
+
// catch it: if OpenAPI adds or removes a config property, a hand-copied list stays green while the
|
|
138
|
+
// surface lies. So derive the required/optional sets from the PARSED spec and compare the sets
|
|
139
|
+
// directly — the vocabulary cannot silently drift from the contract it claims to mirror. This
|
|
140
|
+
// guards EVERY kind, not just `agent`: the connector entry omitting the real `timeout` field
|
|
141
|
+
// (PR #851 review) is the same drift class the agent-only guard missed.
|
|
142
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
143
|
+
const spec = parseYaml(readFileSync(`${ROOT}openapi.yaml`, "utf8")) as Record<string, any>;
|
|
144
|
+
|
|
145
|
+
const vocab = deliveryGraphVocabulary();
|
|
146
|
+
for (const entry of vocab.nodeKinds) {
|
|
147
|
+
const nodeSchemaName = `DeliveryNode${entry.kind[0]!.toUpperCase()}${entry.kind.slice(1)}`;
|
|
148
|
+
let configSchema = nodeConfigSchema(spec, nodeSchemaName, entry.configKey);
|
|
149
|
+
assert.ok(
|
|
150
|
+
configSchema,
|
|
151
|
+
`openapi.yaml components.schemas.${nodeSchemaName} must carry an inline '${entry.configKey}' config sub-schema`,
|
|
152
|
+
);
|
|
153
|
+
// The `wait` kind's config is `$ref: "#/components/schemas/ReadinessProbe"` — follow the reference.
|
|
154
|
+
if (typeof configSchema!.$ref === "string") {
|
|
155
|
+
const refName = configSchema!.$ref.replace(/^#\/components\/schemas\//, "");
|
|
156
|
+
configSchema = spec?.components?.schemas?.[refName];
|
|
157
|
+
assert.ok(configSchema?.properties, `${refName} (the wait config contract) must declare its properties`);
|
|
158
|
+
}
|
|
159
|
+
const { required, optional } = fieldSets(configSchema!);
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
[...entry.requiredFields].sort(),
|
|
162
|
+
required,
|
|
163
|
+
`${entry.kind}.requiredFields drifted from ${nodeSchemaName}.${entry.configKey}.required in openapi.yaml`,
|
|
164
|
+
);
|
|
165
|
+
assert.deepEqual(
|
|
166
|
+
[...entry.optionalFields].sort(),
|
|
167
|
+
optional,
|
|
168
|
+
`${entry.kind}.optionalFields drifted from ${nodeSchemaName}.${entry.configKey}'s optional properties in openapi.yaml`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The non-obvious rules an authoring agent must learn from the surface prose: an ABSENT repository
|
|
173
|
+
// is not compile-rejected (a run-level fallback can satisfy it) — it fails at the OPERATOR's
|
|
174
|
+
// Dispatch; and the per-node SLA timeout override exists.
|
|
175
|
+
const agent = vocab.nodeKinds.find((n) => n.kind === "agent");
|
|
176
|
+
assert.ok(agent, "agent node-kind entry must exist");
|
|
177
|
+
assert.match(agent.summary, /resolve to no repository/, "names the dispatch-time failure the author must pre-empt");
|
|
178
|
+
assert.match(agent.summary, /invalid-node-repository/, "names the compile-time validation for a present value");
|
|
179
|
+
assert.match(agent.summary, /repoless/, "names the checkout-less opt-out");
|
|
180
|
+
assert.match(agent.summary, /per-node ISO-8601 `timeout`/, "names the per-node SLA timeout override (#505)");
|
|
181
|
+
});
|
|
@@ -105,13 +105,29 @@ const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
|
|
|
105
105
|
agent: {
|
|
106
106
|
configKey: "agent",
|
|
107
107
|
requiredFields: ["jobType"],
|
|
108
|
-
optionalFields: ["prompt", "converge", "merge"],
|
|
108
|
+
optionalFields: ["repository", "baseBranch", "prompt", "converge", "merge", "timeout"],
|
|
109
109
|
sideEffecting: true,
|
|
110
110
|
mayEmit: true,
|
|
111
111
|
summary:
|
|
112
|
-
"A worker runs an agent job type (the fan-out body, e.g. `senior:feature`)
|
|
112
|
+
"A worker runs an agent job type (the fan-out body, e.g. `senior:feature`) in an isolated " +
|
|
113
|
+
"checkout. Per-node provisioning (#739/#776): declare `repository` (`owner/repo`, no `.git`/URL) " +
|
|
114
|
+
"on EVERY agent node — the harness clones it at `baseBranch` (omit it to use the run-level " +
|
|
115
|
+
"dispatch `baseBranch`, else the repo's default branch) and cuts `feat/<node.id>` off it " +
|
|
116
|
+
"(when that derived ref is a valid Git branch name; an id like `a..b`/`a.lock` yields an " +
|
|
117
|
+
"ill-formed ref, so the harness omits `branch.create` and the agent cuts its own branch). A node " +
|
|
118
|
+
"with no `repository` can only be satisfied by the run-level dispatch fallback, so COMPILE " +
|
|
119
|
+
"stages it without a warning; at Dispatch it resolves to the run-level `repository` + `baseBranch` " +
|
|
120
|
+
"fallback when the operator supplies one, and the Dispatch fails with `N agent node(s) " +
|
|
121
|
+
"resolve to no repository (…)` ONLY when neither the node nor the run supplies a repository " +
|
|
122
|
+
"(a present value IS compile-validated: `invalid-node-repository`/" +
|
|
123
|
+
"`invalid-node-base-branch`); a genuinely checkout-less graph must be dispatched `repoless: true`, " +
|
|
124
|
+
"which strips isolation from every node. First-class " +
|
|
113
125
|
"`converge?`/`merge?` cell-policy flags declare review-convergence / landing intent (`merge` " +
|
|
114
126
|
"requires `converge`); a raw `senior:converge`/`senior:merge` jobType is rejected (`raw-converge-node`). " +
|
|
127
|
+
"An optional per-node ISO-8601 `timeout` (#505) overrides the run-level `nodeTimeout` (and the " +
|
|
128
|
+
"`PT1H` default) for THIS node's bounded-timeout → escalate boundary, so a legitimately-long node " +
|
|
129
|
+
"(e.g. a full `senior:feature`) outlasts a quick gate without a spurious escalation; absent → the " +
|
|
130
|
+
"run/default value. " +
|
|
115
131
|
"An `agent` that opens a PR emits it as a `pr`-typed fact so downstream connector/wait nodes late-bind it.",
|
|
116
132
|
},
|
|
117
133
|
wait: {
|
|
@@ -138,14 +154,16 @@ const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
|
|
|
138
154
|
connector: {
|
|
139
155
|
configKey: "connector",
|
|
140
156
|
requiredFields: ["target"],
|
|
141
|
-
optionalFields: ["dedupeKey", "payload"],
|
|
157
|
+
optionalFields: ["dedupeKey", "payload", "timeout"],
|
|
142
158
|
sideEffecting: true,
|
|
143
159
|
mayEmit: true,
|
|
144
160
|
summary:
|
|
145
161
|
"An automated, side-effecting outbound action. `payload` for a converge target is " +
|
|
146
162
|
"`{ pr, autoMerge?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
|
|
147
163
|
"reference, or omitted to auto-bind the single incoming `pr` fact). Carries a `dedupeKey` " +
|
|
148
|
-
"(at-least-once safe).
|
|
164
|
+
"(at-least-once safe). An optional per-node ISO-8601 `timeout` (#505) overrides the run-level " +
|
|
165
|
+
"`nodeTimeout` (and the `PT1H` default) for THIS node's bounded-timeout → escalate boundary; " +
|
|
166
|
+
"absent → the run/default value. See connectorTargets for which targets are real vs. forward-declared.",
|
|
149
167
|
},
|
|
150
168
|
};
|
|
151
169
|
|
package/app/github.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
// `fetchRepoFile` from here): `classifyMergeability` reads a repo's declared required checks to gate
|
|
18
18
|
// a merge independently of GitHub branch protection.
|
|
19
19
|
import type { MergeProtocol } from "./mergeProtocol.ts";
|
|
20
|
+
import { parsePr } from "./prParse.ts";
|
|
20
21
|
|
|
21
22
|
/** A GitHub pull-request review, narrowed to the fields the poller needs. */
|
|
22
23
|
export interface GhReview {
|
|
@@ -2020,9 +2021,9 @@ export async function createPullRequest(
|
|
|
2020
2021
|
]);
|
|
2021
2022
|
// `gh pr create` prints the new PR's URL on stdout; parse its number from the canonical path.
|
|
2022
2023
|
const url = out.trim().split(/\s+/).pop() ?? "";
|
|
2023
|
-
const
|
|
2024
|
-
if (!
|
|
2025
|
-
return { number:
|
|
2024
|
+
const pr = parsePr(url); // the ONE PR-shape grammar (#856)
|
|
2025
|
+
if (!pr) throw new Error(`could not parse a PR number from \`gh pr create\` output: ${out.trim()}`);
|
|
2026
|
+
return { number: pr.number, url };
|
|
2026
2027
|
}
|
|
2027
2028
|
if (!token) return null;
|
|
2028
2029
|
const r = await fetch(`https://api.github.com/repos/${repo}/pulls`, {
|
package/app/mcpToolSurface.ts
CHANGED
|
@@ -116,8 +116,14 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
|
|
|
116
116
|
* `startFeature` body variants (`FeatureStartByIssue`/`FeatureStartByUrl`) — the Feature tab's
|
|
117
117
|
* "Human approval before merge" checkbox. Measured 87,293 bytes serialized (86,862 → 87,293 — over
|
|
118
118
|
* the old ceiling's 138-byte headroom). Deliberate, documented growth — not schema fat.
|
|
119
|
+
*
|
|
120
|
+
* RAISE PROVENANCE — 87_500 → 88_000 (#857): the `pr`-kind probe now also accepts a GitHub PR URL
|
|
121
|
+
* (`https://github.com/owner/repo/pull/123`) alongside the `owner/repo#123` shorthand, so the shared
|
|
122
|
+
* `target` description in `openapi.yaml` documents the new accepted handle. Measured 87,559 bytes
|
|
123
|
+
* serialized (87,497 → 87,559 — over the old ceiling's 3-byte headroom). Deliberate, documented
|
|
124
|
+
* growth of a real new accepted input format — not schema fat.
|
|
119
125
|
*/
|
|
120
|
-
export const MCP_SURFACE_BYTES_BUDGET =
|
|
126
|
+
export const MCP_SURFACE_BYTES_BUDGET = 88_000;
|
|
121
127
|
|
|
122
128
|
/**
|
|
123
129
|
* The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// #857 — pin `parseIssue`'s accepted grammar. Its shorthand branch delegates to the canonical
|
|
2
|
+
// `parsePr` (single `owner/repo#N` grammar, #856), and `parsePr` ALSO accepts a `/pull/<n>` URL — so
|
|
3
|
+
// without a guard, every issue-target door (startPlan/startFeature/startEpicSet, plan deps) would
|
|
4
|
+
// silently widen to admit a PR URL. These tests pin that `parseIssue`:
|
|
5
|
+
// • accepts an `/issues/<n>` URL and the bare `owner/repo#N` shorthand, and
|
|
6
|
+
// • REJECTS a PR URL (and any other non-`/issues/` GitHub URL), failing closed rather than
|
|
7
|
+
// resolving it by accident off the shared issue/PR number space.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals } from "#test-assert";
|
|
10
|
+
import { parseIssue } from "./plan.ts";
|
|
11
|
+
|
|
12
|
+
test("parseIssue: accepts an /issues/<n> URL", () => {
|
|
13
|
+
assertEquals(parseIssue("https://github.com/o/r/issues/42"), {
|
|
14
|
+
repo: "o/r",
|
|
15
|
+
number: 42,
|
|
16
|
+
url: "https://github.com/o/r/issues/42",
|
|
17
|
+
planKey: "o/r#42",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("parseIssue: accepts the bare owner/repo#N shorthand (delegated to the canonical parsePr)", () => {
|
|
22
|
+
assertEquals(parseIssue("o/r#42"), {
|
|
23
|
+
repo: "o/r",
|
|
24
|
+
number: 42,
|
|
25
|
+
url: "https://github.com/o/r/issues/42",
|
|
26
|
+
planKey: "o/r#42",
|
|
27
|
+
});
|
|
28
|
+
// trims surrounding whitespace like every caller's `.trim()` path
|
|
29
|
+
assertEquals(parseIssue(" o/r#7 ")?.planKey, "o/r#7");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("parseIssue: accepts shorthand whose repo is literally named github.com (host-spelling gate, not bare substring) (#857)", () => {
|
|
33
|
+
// The non-issue-URL gate keys on the `github.com/` HOST spelling, so a valid shorthand carrying
|
|
34
|
+
// `github.com` as a REPO NAME still parses — parsePr accepts `owner/github.com#N`, so parseIssue must.
|
|
35
|
+
assertEquals(parseIssue("owner/github.com#42"), {
|
|
36
|
+
repo: "owner/github.com",
|
|
37
|
+
number: 42,
|
|
38
|
+
url: "https://github.com/owner/github.com/issues/42",
|
|
39
|
+
planKey: "owner/github.com#42",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("parseIssue: REJECTS a spoofed/non-GitHub host, userinfo trick, or prose-wrapped issue URL (anchored) (#857)", () => {
|
|
44
|
+
// The `/issues/<n>` URL branch was UNANCHORED (`/github\.com\/…/`), so `github.com` matched as a
|
|
45
|
+
// SUBSTRING — a spoofed host suffix (`notgithub.com`), a userinfo trick (`github.com@evil.com`),
|
|
46
|
+
// or a prose-wrapped occurrence (`see https://github.com/…`) all parsed, and issue intake then
|
|
47
|
+
// operated on the embedded `owner/repo` — a DIFFERENT target than the submitted value. The branch
|
|
48
|
+
// must anchor the whole value to the exact GitHub host, mirroring the hardened `parsePr` URL
|
|
49
|
+
// branch, and fail closed on every one of these.
|
|
50
|
+
for (const bad of [
|
|
51
|
+
"https://notgithub.com/o/r/issues/42",
|
|
52
|
+
"https://evilgithub.com/o/r/issues/42",
|
|
53
|
+
"https://github.com.evil.com/o/r/issues/42",
|
|
54
|
+
"https://github.com@evil.com/o/r/issues/42",
|
|
55
|
+
"see https://github.com/o/r/issues/42",
|
|
56
|
+
"https://github.com/o/r/issues/42 and more text",
|
|
57
|
+
"ftp://github.com/o/r/issues/42",
|
|
58
|
+
]) {
|
|
59
|
+
assertEquals(parseIssue(bad), null, `expected null for ${JSON.stringify(bad)}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("parseIssue: still ACCEPTS canonical issue URLs incl. case-insensitive host, www., and suffixes (#857)", () => {
|
|
64
|
+
// Anchoring must not narrow the documented grammar: optional scheme, optional `www.`, a
|
|
65
|
+
// case-insensitive host, and the same trailing `/path`/`?query`/`#fragment` suffixes `parsePr`
|
|
66
|
+
// preserves all still parse.
|
|
67
|
+
assertEquals(parseIssue("https://github.com/o/r/issues/42")?.planKey, "o/r#42");
|
|
68
|
+
assertEquals(parseIssue("github.com/o/r/issues/42")?.planKey, "o/r#42");
|
|
69
|
+
assertEquals(parseIssue("https://www.github.com/o/r/issues/42")?.planKey, "o/r#42");
|
|
70
|
+
assertEquals(parseIssue("HTTPS://GITHUB.COM/o/r/issues/42")?.planKey, "o/r#42");
|
|
71
|
+
assertEquals(parseIssue("https://github.com/o/r/issues/42#issuecomment-1")?.planKey, "o/r#42");
|
|
72
|
+
assertEquals(parseIssue("https://github.com/o/r/issues/42?foo=bar")?.planKey, "o/r#42");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("parseIssue: REJECTS a PR URL — parsePr would accept it, but a PR URL is not an issue target (#857)", () => {
|
|
76
|
+
assertEquals(parseIssue("https://github.com/o/r/pull/7"), null);
|
|
77
|
+
// even with a trailing path segment or embedded in prose, the non-issue GitHub URL is rejected
|
|
78
|
+
assertEquals(parseIssue("https://github.com/o/r/pull/7/files"), null);
|
|
79
|
+
assertEquals(parseIssue("please land https://github.com/o/r/pull/7 now"), null);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("parseIssue: REJECTS other non-/issues/ GitHub URLs (commit, blob, …) and junk", () => {
|
|
83
|
+
assertEquals(parseIssue("https://github.com/o/r/commit/deadbeef"), null);
|
|
84
|
+
assertEquals(parseIssue("https://github.com/o/r"), null);
|
|
85
|
+
assertEquals(parseIssue(""), null);
|
|
86
|
+
assertEquals(parseIssue("not a ref"), null);
|
|
87
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./github.ts";
|
|
30
30
|
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
31
31
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
32
|
+
import { parsePr } from "./prParse.ts";
|
|
32
33
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
33
34
|
import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
|
|
34
35
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
@@ -473,21 +474,39 @@ export interface ParsedIssue {
|
|
|
473
474
|
planKey: string;
|
|
474
475
|
}
|
|
475
476
|
|
|
476
|
-
/** Parse "owner/repo#123" or a canonical issue URL into its parts.
|
|
477
|
-
* (app/
|
|
477
|
+
/** Parse "owner/repo#123" or a canonical issue URL into its parts. The URL branch is the /issues/
|
|
478
|
+
* spelling; the shorthand branch DELEGATES to the canonical {@link parsePr} (`app/prParse.ts`) — the
|
|
479
|
+
* single source of truth for the `owner/repo#N` shape — rather than carrying a second copy of its
|
|
480
|
+
* regex (#856/#857: a duplicated shorthand grammar is exactly the drift the single-grammar guard bans).
|
|
481
|
+
*
|
|
482
|
+
* `parseIssue` admits an ISSUE reference, and `parsePr` ALSO accepts a `/pull/<n>` URL, so delegating
|
|
483
|
+
* raw input to it would silently widen every issue-target door (startPlan/startFeature/startEpicSet,
|
|
484
|
+
* plan deps) to accept a PR URL — resolvable-by-accident (issues and PRs share GitHub's number space)
|
|
485
|
+
* but undocumented and off-contract (#857). So only the bare `owner/repo#N` shorthand is delegated: a
|
|
486
|
+
* GitHub URL that is not the `/issues/` spelling handled above (a PR URL, a commit URL, …) fails closed
|
|
487
|
+
* here. The gate keys on the `github.com/` HOST spelling (host + path separator), which every GitHub
|
|
488
|
+
* URL carries but the `owner/repo#N` shorthand never does — NOT the bare `github.com` substring, which
|
|
489
|
+
* a valid shorthand CAN carry as a repository name (`owner/github.com#42`, which `parsePr` accepts).
|
|
490
|
+
* So the host-spelling gate rejects every non-issue GitHub URL while leaving the shorthand — including
|
|
491
|
+
* a repo literally named `github.com` — to the one canonical grammar. */
|
|
478
492
|
export function parseIssue(input: string): ParsedIssue | null {
|
|
479
493
|
const s = input.trim();
|
|
480
|
-
|
|
494
|
+
// ANCHORED to the whole string with an exact `github.com` host (optional scheme + `www.`),
|
|
495
|
+
// mirroring the hardened `parsePr` URL branch: the old UNANCHORED `github\.com/…` matched
|
|
496
|
+
// `github.com` as a SUBSTRING — a spoofed host suffix (`notgithub.com`), a userinfo trick
|
|
497
|
+
// (`github.com@evil.com`), or any prose-wrapped occurrence — and issue intake then operated on the
|
|
498
|
+
// embedded `owner/repo`, a DIFFERENT target than the submitted value (#857 review). Supported URL
|
|
499
|
+
// suffixes (`?query`, `#fragment`) are preserved via the trailing group.
|
|
500
|
+
const m = s.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/?#].*)?$/i);
|
|
481
501
|
if (m) {
|
|
482
502
|
const repo = `${m[1]}/${m[2]}`;
|
|
483
503
|
const number = Number(m[3]);
|
|
484
504
|
return { repo, number, url: `https://github.com/${repo}/issues/${number}`, planKey: `${repo}#${number}` };
|
|
485
505
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
return { repo, number, url: `https://github.com/${repo}/issues/${number}`, planKey: `${repo}#${number}` };
|
|
506
|
+
if (/github\.com\//i.test(s)) return null;
|
|
507
|
+
const pr = parsePr(s);
|
|
508
|
+
if (pr) {
|
|
509
|
+
return { repo: pr.repo, number: pr.number, url: `https://github.com/${pr.repo}/issues/${pr.number}`, planKey: pr.prKey };
|
|
491
510
|
}
|
|
492
511
|
return null;
|
|
493
512
|
}
|
package/app/prParse.ts
CHANGED
|
@@ -18,13 +18,26 @@ export function parsePr(input: unknown): ParsedPr | null {
|
|
|
18
18
|
// a retrying job. Fail closed to `null` here so every caller resolves safely instead of throwing.
|
|
19
19
|
if (typeof input !== "string") return null;
|
|
20
20
|
const s = input.trim();
|
|
21
|
-
|
|
21
|
+
// ANCHORED to the whole string with an exact `github.com` host (optional scheme + `www.`): an
|
|
22
|
+
// unanchored `github\.com/…` matched `github.com` as a SUBSTRING — a spoofed host suffix
|
|
23
|
+
// (`notgithub.com`), a userinfo trick (`github.com@evil.com`), or any prose-wrapped occurrence —
|
|
24
|
+
// and the pr/epic probe would then silently poll the embedded `owner/repo` (a DIFFERENT target
|
|
25
|
+
// than the submitted value) or time out (#857). Supported URL suffixes (`/files`, `?query`,
|
|
26
|
+
// `#fragment`) are preserved via the trailing group. This is the ONE PR-URL grammar; every
|
|
27
|
+
// downstream (`parsePrTarget`/`parsePlanKey` in readiness.ts, etc.) inherits the anchoring.
|
|
28
|
+
let m = s.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i);
|
|
22
29
|
if (m) {
|
|
23
30
|
const repo = `${m[1]}/${m[2]}`;
|
|
24
31
|
const number = Number(m[3]);
|
|
25
32
|
return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
|
|
26
33
|
}
|
|
27
|
-
|
|
34
|
+
// The shorthand is EXACTLY `owner/repo#N` — precisely one `/`. The old capture (`[^/]+/[^#]+`)
|
|
35
|
+
// admitted EXTRA slashes, so a URL-shaped value (`https://example.com/o/r#7`, `//host/o/r#7`) or a
|
|
36
|
+
// multi-slash path (`a/b/c#9`) parsed to a "PR" whose repo is the whole multi-slash string, and a
|
|
37
|
+
// canonical-grammar caller (readiness `parsePrTarget`, the workers) then polled that wrong target
|
|
38
|
+
// via `gh` (#857 review). Fail closed on every extra-slash impersonator: a repo segment carries no
|
|
39
|
+
// `/`, so the only slash in a valid shorthand is the owner/repo separator.
|
|
40
|
+
m = s.match(/^([^/#]+\/[^/#]+)#(\d+)$/);
|
|
28
41
|
if (m) {
|
|
29
42
|
const repo = m[1];
|
|
30
43
|
const number = Number(m[2]);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// #856 — ONE PR-shape grammar. `app/prParse.ts` `parsePr` is the single source of truth for "is this a
|
|
2
|
+
// PR reference" (`owner/repo#N` or a GitHub PR URL). A second, stricter PR regex in the readiness probe
|
|
3
|
+
// made a delivery-graph `wait[pr merged]` incident on a PR URL the agent contract explicitly allows,
|
|
4
|
+
// while the converge-merge connector (canonical parser) accepted it. Guard the class: no other
|
|
5
|
+
// production module may carry its own copy of EITHER `parsePr` branch — the `/pull/<digits>` URL
|
|
6
|
+
// spelling OR the `owner/repo#<digits>` shorthand (the original #856 duplicate was the shorthand, so
|
|
7
|
+
// guarding only the URL spelling would let the exact failure mode regrow).
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { join, relative, resolve } from "node:path";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
|
|
13
|
+
const ROOT = resolve(import.meta.dirname, "..");
|
|
14
|
+
const DIRS = ["app", "workers", "operations", "actions"];
|
|
15
|
+
// The scan must cover EVERY production source file, not only the package subdirs above: the repo's
|
|
16
|
+
// production entrypoint + host glue (`main.ts`) lives at the repo ROOT, so a duplicate grammar added
|
|
17
|
+
// there would leave all three guards green while reopening the #856 class. Include every root-level
|
|
18
|
+
// `.ts` module (not just a hardcoded `main.ts`) so any future root production source is covered too.
|
|
19
|
+
const ROOT_FILES_FILTER = (f: string) => f.endsWith(".ts") && !f.endsWith(".test.ts");
|
|
20
|
+
// The canonical home of each single-source grammar: the PR-shape parser lives in app/prParse.ts, and
|
|
21
|
+
// the issue-shape parser (its `/issues/<n>` URL branch) lives in app/plan.ts `parseIssue`.
|
|
22
|
+
const PARSE_PR = "app/prParse.ts";
|
|
23
|
+
const PARSE_ISSUE = "app/plan.ts";
|
|
24
|
+
|
|
25
|
+
function walk(dir: string, out: string[]) {
|
|
26
|
+
let entries: string[];
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(dir);
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
const p = join(dir, e);
|
|
34
|
+
if (statSync(p).isDirectory()) walk(p, out);
|
|
35
|
+
else if (p.endsWith(".ts") && !p.endsWith(".test.ts")) out.push(p);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The spellings of a duplicate reference-shape grammar, matched against a module's SOURCE TEXT. Each
|
|
40
|
+
// is matched DIGIT-CLASS-, ANCHOR-, and CAPTURE-SPELLING-AGNOSTICALLY — pinned to a capture group
|
|
41
|
+
// (plain OR named) opening on a digit atom (`\d` or `[0-9]`), NOT to one verbatim `#(\d+)$` spelling —
|
|
42
|
+
// so a drifted duplicate cannot stay green by respelling the capture (#857 review): `#(\d+)`,
|
|
43
|
+
// `#([0-9]+)$`, `#(\d+)\b`, `issues/([0-9]+)`, AND the named-capture forms `#(?<number>\d+)`,
|
|
44
|
+
// `/pull/(?<number>\d+)`, `/issues/(?<n>[0-9]+)` are ALL caught. Pinning the scan to a BARE `(` before
|
|
45
|
+
// the digit atom was the fail-open gap: a named capture inserts `?<name>` between the `(` and the
|
|
46
|
+
// digit, so the old `…\(${DIGITS}` patterns never matched it and a duplicate grammar in that spelling
|
|
47
|
+
// regressed the single-grammar invariant while every guard stayed green:
|
|
48
|
+
// 1. the PR-URL branch — a `/pull/(<digits>` capture regex (`github\.com/…/pull/(\d+)`);
|
|
49
|
+
// 2. the PR shorthand branch — a `#(<digits>` capture regex (`^([^/]+/[^#]+)#(\d+)$`), the exact
|
|
50
|
+
// duplicate that caused #856 — matched with no trailing `)$` so any drifted spelling is caught;
|
|
51
|
+
// 3. the issue-URL branch — an `/issues/(<digits>` capture regex (`parseIssue`'s URL spelling).
|
|
52
|
+
// Written per-branch so this guard's own pattern stays self-consistent with the ban it enforces (a
|
|
53
|
+
// combined `#(\d+)` scan would flag the pattern text).
|
|
54
|
+
const DIGITS = /(?:\\d|\[0-9\])/.source; // `\d` or `[0-9]` as it appears in regex SOURCE text
|
|
55
|
+
// A capture-group OPENING in regex source: a bare `(` OR a named capture `(?<name>` — the `?<name>`
|
|
56
|
+
// prefix is what the old bare-`\(` scan missed. `(?:\?<[A-Za-z_][A-Za-z0-9_]*>)?` is the optional
|
|
57
|
+
// named-capture tag, so both `(\d+)` and `(?<number>\d+)` match. (Lookahead/lookbehind `(?=…)`/`(?<=…)`
|
|
58
|
+
// and non-capturing `(?:…)` openers are not capture groups a parser extracts a number from, so they are
|
|
59
|
+
// intentionally out of scope — the guard bans a duplicate *capture* of the digits.)
|
|
60
|
+
const CAPTURE_OPEN = `\\((?:\\?<[A-Za-z_][A-Za-z0-9_]*>)?`;
|
|
61
|
+
const PULL_URL_REGEX = new RegExp(`\\\\/pull\\\\/${CAPTURE_OPEN}${DIGITS}`);
|
|
62
|
+
const HASH_NUMBER_REGEX = new RegExp(`#${CAPTURE_OPEN}${DIGITS}`);
|
|
63
|
+
const ISSUES_URL_REGEX = new RegExp(`\\\\/issues\\\\/${CAPTURE_OPEN}${DIGITS}`);
|
|
64
|
+
|
|
65
|
+
function collectProductionFiles(): string[] {
|
|
66
|
+
const files: string[] = [];
|
|
67
|
+
for (const d of DIRS) walk(join(ROOT, d), files);
|
|
68
|
+
// Root-level production modules (e.g. main.ts) are not under any package dir — scan them too.
|
|
69
|
+
for (const e of readdirSync(ROOT)) {
|
|
70
|
+
const p = join(ROOT, e);
|
|
71
|
+
if (!statSync(p).isDirectory() && ROOT_FILES_FILTER(e)) files.push(p);
|
|
72
|
+
}
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function offenders(regex: RegExp, allowed: string): string[] {
|
|
77
|
+
return collectProductionFiles()
|
|
78
|
+
.map((f) => relative(ROOT, f))
|
|
79
|
+
.filter((rel) => rel !== allowed)
|
|
80
|
+
.filter((rel) => regex.test(readFileSync(join(ROOT, rel), "utf8")));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("#856/#857: the grammar guard scans the root production entrypoint (main.ts), not only the package dirs", () => {
|
|
84
|
+
// A duplicate grammar in the root entrypoint/host glue must not slip past the three guards below.
|
|
85
|
+
const scanned = collectProductionFiles().map((f) => relative(ROOT, f));
|
|
86
|
+
assert.ok(
|
|
87
|
+
scanned.includes("main.ts"),
|
|
88
|
+
"the guard's scanned set must include the root main.ts production entrypoint",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("#856: only app/prParse.ts defines a PR-URL regex (`/pull/<digits>`) — everyone else calls parsePr", () => {
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
offenders(PULL_URL_REGEX, PARSE_PR),
|
|
95
|
+
[],
|
|
96
|
+
"use parsePr from app/prParse.ts instead of a local PR-URL regex",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("#856/#857: only app/prParse.ts defines the `owner/repo#<digits>` shorthand regex — everyone else calls parsePr", () => {
|
|
101
|
+
// The original #856 duplicate grammar was the SHORTHAND branch (`/^(.+?)#(\d+)$/`), with no
|
|
102
|
+
// `/pull/` fragment — a URL-only guard stays green while the exact failure mode regrows. Ban the
|
|
103
|
+
// shorthand capture regex everywhere outside the canonical module too (a `#<digits>` fragment-SHAPE
|
|
104
|
+
// test with no capture group, e.g. the deliveryGraph redaction exemption, is not a PR parser and
|
|
105
|
+
// is not matched).
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
offenders(HASH_NUMBER_REGEX, PARSE_PR),
|
|
108
|
+
[],
|
|
109
|
+
"use parsePr from app/prParse.ts instead of a local owner/repo#N regex",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("#857: only app/plan.ts defines the issue-URL regex (`/issues/<digits>`) — everyone else calls parseIssue", () => {
|
|
114
|
+
// `parseIssue` is the single source of the issue-shape grammar, exactly as `parsePr` is for the PR
|
|
115
|
+
// shape. Its `/issues/<n>` URL branch was unguarded, so a second issue-URL parser could drift in and
|
|
116
|
+
// reopen the #856 class on the issue side. Ban the capture regex everywhere outside app/plan.ts.
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
offenders(ISSUES_URL_REGEX, PARSE_ISSUE),
|
|
119
|
+
[],
|
|
120
|
+
"use parseIssue from app/plan.ts instead of a local /issues/<n> regex",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("#857: the guard's scan catches a duplicate grammar in ANY capture spelling (mutation fixtures)", () => {
|
|
125
|
+
// The scan must be CAPTURE-SPELLING-AGNOSTIC: a duplicate parser that respells the capture group
|
|
126
|
+
// (named capture, `[0-9]` for `\d`, a drifted anchor) must still be flagged, or the single-grammar
|
|
127
|
+
// invariant regresses behind a green guard. These are the regex SOURCE fragments a duplicate parser
|
|
128
|
+
// would carry; each must match its branch's scan regex. (The canonical modules' own real spellings
|
|
129
|
+
// are covered by the allowlist in the tests above — these fixtures prove the *detector* fires on the
|
|
130
|
+
// drifted forms a copy-paste would actually produce.)
|
|
131
|
+
// Fixtures are written as the duplicate's regex SOURCE TEXT appears in a `.ts` module — slashes
|
|
132
|
+
// escaped (`\/`) and the digit atom as `\d` / `[0-9]` — exactly what `readFileSync` reads and the
|
|
133
|
+
// scan regexes are built to match.
|
|
134
|
+
const dupSpellings: Array<{ branch: RegExp; src: string; why: string }> = [
|
|
135
|
+
// PR-URL branch — plain, named-capture, and [0-9] spellings of `/pull/(<digits>`:
|
|
136
|
+
{ branch: PULL_URL_REGEX, src: "github\\.com\\/([^/]+)\\/([^/]+)\\/pull\\/(\\d+)", why: "plain /pull/(\\d+)" },
|
|
137
|
+
{ branch: PULL_URL_REGEX, src: "\\/pull\\/(?<number>\\d+)", why: "named /pull/(?<number>\\d+)" },
|
|
138
|
+
{ branch: PULL_URL_REGEX, src: "\\/pull\\/([0-9]+)", why: "[0-9] /pull/([0-9]+)" },
|
|
139
|
+
// PR shorthand branch — plain and named-capture spellings of `#(<digits>`:
|
|
140
|
+
{ branch: HASH_NUMBER_REGEX, src: "^([^/]+\\/[^#]+)#(\\d+)$", why: "plain #(\\d+)" },
|
|
141
|
+
{ branch: HASH_NUMBER_REGEX, src: "#(?<number>\\d+)", why: "named #(?<number>\\d+)" },
|
|
142
|
+
{ branch: HASH_NUMBER_REGEX, src: "#([0-9]+)\\b", why: "[0-9] #([0-9]+)" },
|
|
143
|
+
// Issue-URL branch — plain and named-capture spellings of `/issues/(<digits>`:
|
|
144
|
+
{ branch: ISSUES_URL_REGEX, src: "\\/issues\\/(\\d+)", why: "plain /issues/(\\d+)" },
|
|
145
|
+
{ branch: ISSUES_URL_REGEX, src: "\\/issues\\/(?<n>[0-9]+)", why: "named /issues/(?<n>[0-9]+)" },
|
|
146
|
+
];
|
|
147
|
+
for (const { branch, src, why } of dupSpellings) {
|
|
148
|
+
assert.ok(branch.test(src), `the guard must flag a duplicate spelled as ${why} (${src})`);
|
|
149
|
+
}
|
|
150
|
+
// And a NON-capturing numeric match (no capture group — `(?:\d+)` or a bare `\d+`) is NOT a parser
|
|
151
|
+
// extraction and must NOT be flagged, so the guard does not false-positive on innocent digit matches.
|
|
152
|
+
assert.ok(!HASH_NUMBER_REGEX.test("#(?:\\d+)"), "a non-capturing #(?:\\d+) is not a duplicate parser");
|
|
153
|
+
assert.ok(!PULL_URL_REGEX.test("\\/pull\\/(?:\\d+)"), "a non-capturing /pull/(?:\\d+) is not a duplicate parser");
|
|
154
|
+
});
|
package/app/readiness.test.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// pure surface: descriptor parse/validation, each kind's matcher, the injectable `probeOnce`
|
|
5
5
|
// dispatch (no network / subprocess), backoff, the ms→ISO timeout derivation, and log redaction.
|
|
6
6
|
import { test } from "node:test";
|
|
7
|
+
import { parsePr } from "./prParse.ts";
|
|
7
8
|
import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "#test-assert";
|
|
8
9
|
import {
|
|
9
10
|
type CommandResult,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
type EpicObservation,
|
|
34
35
|
isFactRefTarget,
|
|
35
36
|
parseEpicLineage,
|
|
37
|
+
parsePlanKey,
|
|
36
38
|
summariseCapabilityCandidates,
|
|
37
39
|
parsePrView,
|
|
38
40
|
parseReleases,
|
|
@@ -525,6 +527,113 @@ test("parseProbe: a valid pr probe round-trips its prState", () => {
|
|
|
525
527
|
assertEquals(p.match?.prState, "mergeable");
|
|
526
528
|
});
|
|
527
529
|
|
|
530
|
+
// ── #856: a PR URL is a valid pr target — the agent contract allows "a URL or owner/repo#N" ──────────
|
|
531
|
+
// Field incident (merlin, graph 5e36636255ab): an agent returned its `pr` as a GitHub URL — explicitly
|
|
532
|
+
// allowed by the injected agent contract — the converge-merge connector accepted it (canonical
|
|
533
|
+
// `parsePr`), but the downstream `wait[pr merged]` probe threw at parse (a second, stricter PR regex)
|
|
534
|
+
// and the job incidented with no retries.
|
|
535
|
+
test("#856 parseProbe: a PR URL target parses (same shapes as the canonical parsePr)", () => {
|
|
536
|
+
const p = parseProbe({ kind: "pr", target: "https://github.com/nanobpm/nano-supervisor/pull/32", match: { prState: "merged" } });
|
|
537
|
+
assertEquals(p.kind, "pr");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("#856 probeOnce pr: a PR URL target reads the right repo + number", async () => {
|
|
541
|
+
const cap: { cmd?: string } = {};
|
|
542
|
+
const exec = stubExec({ command: { code: 0, stdout: JSON.stringify({ state: "MERGED", mergedAt: "2026-01-01T00:00:00Z" }), stderr: "" }, capture: cap });
|
|
543
|
+
const p = parseProbe({ kind: "pr", target: "https://github.com/nanobpm/nano-supervisor/pull/32", match: { prState: "merged" } });
|
|
544
|
+
await probeOnce(p, exec, {});
|
|
545
|
+
assertStringIncludes(cap.cmd ?? "", "gh pr view '32'");
|
|
546
|
+
assertStringIncludes(cap.cmd ?? "", "--repo 'nanobpm/nano-supervisor'");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("#856 parsePrTarget agrees with the canonical parsePr on every PR shape (no second PR-shape grammar)", () => {
|
|
550
|
+
const cases = ["o/r#12", "https://github.com/o/r/pull/12", "github.com/o/r/pull/12", " o/r#12 ", "o/r@12", "o/r", "#12", "o/r/pull/12"];
|
|
551
|
+
for (const c of cases) {
|
|
552
|
+
const canon = parsePr(c);
|
|
553
|
+
assertEquals(parsePrTarget(c), canon ? { repo: canon.repo, number: String(canon.number) } : null, c);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// ── #857: an epic target is a CANONICAL planKey (`owner/repo#N`) — never a URL ────────────────────
|
|
558
|
+
// The epic probe keys off the durable lineage root (`owner/repo#N`, `parseIssue`'s `planKey` shape).
|
|
559
|
+
// Delegating its validation to `parsePr` (whose grammar includes GitHub PR URLs) let a URL pass the
|
|
560
|
+
// epic check; `probeOnce` then queried `/lineage?root=<the URL>`, which matches no lineage root, so
|
|
561
|
+
// the probe could only time out instead of failing loudly at submission. Epic targets stay
|
|
562
|
+
// canonical-key-only; URLs are a `pr`-kind-only shape.
|
|
563
|
+
test("#857 parseProbe: an epic probe REJECTS a GitHub PR URL target (a planKey is owner/repo#N, never a URL)", () => {
|
|
564
|
+
assertThrows(
|
|
565
|
+
() => parseProbe({ kind: "epic", target: "https://github.com/nanobpm/nano-ide/pull/488", match: { epicState: "merged" } }),
|
|
566
|
+
Error,
|
|
567
|
+
"planKey",
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("#857 parseProbe: an epic probe REJECTS an issue-URL target too (only the canonical key shape)", () => {
|
|
572
|
+
assertThrows(
|
|
573
|
+
() => parseProbe({ kind: "epic", target: "https://github.com/nanobpm/nano-ide/issues/488", match: { epicState: "merged" } }),
|
|
574
|
+
Error,
|
|
575
|
+
"planKey",
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("#857 parseProbe: an epic probe still accepts the canonical owner/repo#N planKey", () => {
|
|
580
|
+
const p = parseProbe({ kind: "epic", target: "nanobpm/nano-ide#488", match: { epicState: "merged" } });
|
|
581
|
+
assertEquals(p.kind, "epic");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("#857 probeOnce epic: the lineage read is keyed by the CANONICAL planKey even when the target is padded", async () => {
|
|
585
|
+
// probeOnce must query `/lineage?root=<owner/repo#N>` — the shape lineage roots are stored under —
|
|
586
|
+
// derived from the parsed key, not the raw target string, so a padded/oddly-cased literal cannot
|
|
587
|
+
// silently miss every lineage thread and burn the whole wait budget as a timeout.
|
|
588
|
+
const capture: { url?: string } = {};
|
|
589
|
+
const exec: ProbeExec = {
|
|
590
|
+
async httpGet(url) {
|
|
591
|
+
capture.url = url;
|
|
592
|
+
return { status: 200, body: JSON.stringify({ count: 1, threads: [{ rootRequestKey: "o/r#7", stage: "merged", active: false, prCount: 1 }] }) };
|
|
593
|
+
},
|
|
594
|
+
async run() {
|
|
595
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
const r = await probeOnce(parseProbe({ kind: "epic", target: " o/r#7 " }), exec, { NANO_WORKFORCE_BASE_URL: "http://host:3000" });
|
|
599
|
+
assert(r.ready, "a fully-merged epic goes green");
|
|
600
|
+
assertStringIncludes(capture.url ?? "", "/app/api/lineage?root=o%2Fr%237");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// ── #857 review: parsePlanKey must FAIL CLOSED on URL-shaped / multi-slash impersonators ──────────
|
|
604
|
+
// `pr.prKey === target.trim()` only proves the value ROUND-TRIPS through `parsePr`; the shorthand
|
|
605
|
+
// branch's repo capture admits extra `/`, so a URL-shaped value round-trips to an identical prKey and
|
|
606
|
+
// was wrongly accepted as a canonical planKey (the epic probe then queried it as a lineage root and
|
|
607
|
+
// could only time out). The discriminator must require EXACTLY `owner/repo` (one slash).
|
|
608
|
+
test("#857 parsePlanKey: REJECTS a URL-shaped value that round-trips through parsePr (https://example.com/o/r#7)", () => {
|
|
609
|
+
// The exact advisory case: not github.com, but the shorthand branch still parses it (extra `/` in
|
|
610
|
+
// the repo capture) and `prKey === target` held — so it slipped past the old discriminator.
|
|
611
|
+
assertEquals(parsePlanKey("https://example.com/o/r#7"), null);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("#857 parsePlanKey: REJECTS a scheme-relative URL-shaped value (//host/o/r#7)", () => {
|
|
615
|
+
assertEquals(parsePlanKey("//host/o/r#7"), null);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("#857 parsePlanKey: REJECTS a multi-slash path that is not owner/repo (a/b/c#9)", () => {
|
|
619
|
+
assertEquals(parsePlanKey("a/b/c#9"), null);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("#857 parsePlanKey: still REJECTS a real GitHub PR URL (prKey never equals the URL)", () => {
|
|
623
|
+
assertEquals(parsePlanKey("https://github.com/o/r/pull/7"), null);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("#857 parsePlanKey: still ACCEPTS the canonical owner/repo#N key (exactly one slash)", () => {
|
|
627
|
+
assertEquals(parsePlanKey("owner/repo#7"), { planKey: "owner/repo#7" });
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("#857 parsePlanKey: still ACCEPTS a repo literally named github.com (owner/github.com#42 — one slash)", () => {
|
|
631
|
+
// Regression guard: the discriminator keys on the slash COUNT, not the host substring, so the
|
|
632
|
+
// legitimate `owner/github.com#42` shorthand (which `parseIssue`/`parsePr` deliberately allow) is
|
|
633
|
+
// not collateral damage.
|
|
634
|
+
assertEquals(parsePlanKey("owner/github.com#42"), { planKey: "owner/github.com#42" });
|
|
635
|
+
});
|
|
636
|
+
|
|
528
637
|
// ── #570: a fact-bound pr/epic target DISPATCHES (late-binding), a malformed literal still fails ──
|
|
529
638
|
test("parseProbe: a FACT-BOUND pr target (`<node>.<fact>`) parses (resolved at dispatch, not a literal here) — #570", () => {
|
|
530
639
|
// The documented canonical `agent → converge-merge → wait[pr merged]` shape wires the wait's
|
package/app/readiness.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
|
|
20
20
|
import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
|
|
21
21
|
import { allCheckNames, checkConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
|
|
22
|
+
import { parsePr } from "./prParse.ts";
|
|
22
23
|
import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
|
|
23
24
|
|
|
24
25
|
/** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail
|
|
@@ -276,14 +277,18 @@ export function parseProbe(raw: unknown, opts?: { allowLateBoundTarget?: boolean
|
|
|
276
277
|
// compiler rewrite, so a fact-ref target there can never resolve — it must fail loudly at submit.
|
|
277
278
|
if (kind === "pr" && !(allowLateBoundTarget && isFactRefTarget(target)) && !parsePrTarget(target)) {
|
|
278
279
|
throw new Error(
|
|
279
|
-
`readiness probe (pr): 'target' ('${target}') must be an 'owner/repo#<number>' PR reference (e.g. 'nanobpm/nano-workforce#377')`,
|
|
280
|
+
`readiness probe (pr): 'target' ('${target}') must be an 'owner/repo#<number>' PR reference or a GitHub PR URL (e.g. 'nanobpm/nano-workforce#377')`,
|
|
280
281
|
);
|
|
281
282
|
}
|
|
282
283
|
// An epic edge is keyed by the durable `planKey` (`owner/repo#NN`, the epic issue) — the stable
|
|
283
284
|
// business id, so a resubmit/replay still resolves (issue #568). Validate it as a literal planKey,
|
|
284
285
|
// exempting a fact-bound reference for the same #548 late-binding reason as `pr` above (and gated
|
|
285
286
|
// on the same `allowLateBoundTarget` opt-in, so a non-delivery-graph caller still fails loudly).
|
|
286
|
-
|
|
287
|
+
// The validator is the CANONICAL-KEY-ONLY {@link parsePlanKey}, NOT `parsePrTarget`: `parsePr`'s
|
|
288
|
+
// grammar includes GitHub PR URLs, but a lineage root is only ever stored as `owner/repo#N`, so a
|
|
289
|
+
// URL target here would pass a `parsePrTarget` check and then query `/lineage?root=<the URL>`,
|
|
290
|
+
// matching nothing — the probe could only time out instead of failing loudly at submission (#857).
|
|
291
|
+
if (kind === "epic" && !(allowLateBoundTarget && isFactRefTarget(target)) && !parsePlanKey(target)) {
|
|
287
292
|
throw new Error(
|
|
288
293
|
`readiness probe (epic): 'target' ('${target}') must be an 'owner/repo#<number>' planKey (the epic issue, e.g. 'nanobpm/nano-workforce#374')`,
|
|
289
294
|
);
|
|
@@ -813,19 +818,36 @@ export function matchPr(match: ProbeMatch | undefined, pr: PrObservation): Probe
|
|
|
813
818
|
}
|
|
814
819
|
}
|
|
815
820
|
|
|
816
|
-
/** Split
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
* `owner/repo
|
|
820
|
-
*
|
|
821
|
-
*
|
|
821
|
+
/** Split a PR reference into its repo + numeric PR number, or `null` when it is not one (so
|
|
822
|
+
* `parseProbe` can reject a never-resolvable target loudly). A THIN ADAPTER over the canonical
|
|
823
|
+
* {@link parsePr} (`app/prParse.ts`) — the single PR-shape grammar — so every surface agrees on what a PR
|
|
824
|
+
* reference is: `owner/repo#N` or a GitHub PR URL (`https://github.com/owner/repo/pull/N`). The agent
|
|
825
|
+
* contract tells producers to return "a URL or `owner/repo#N`" and the converge-merge connector accepts
|
|
826
|
+
* both; a second, stricter regex here made the downstream `wait[pr merged]` incident on a URL (#856).
|
|
827
|
+
* An `@N` form is still NOT a PR (`owner/repo@<ref>` is the repo-ref syntax, `parseRepoRef`). */
|
|
822
828
|
export function parsePrTarget(target: string): { repo: string; number: string } | null {
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
+
const pr = parsePr(target);
|
|
830
|
+
return pr ? { repo: pr.repo, number: String(pr.number) } : null;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Split an epic probe target into its canonical planKey, or `null` when it is not one. Unlike
|
|
834
|
+
* {@link parsePrTarget} this accepts ONLY the canonical `owner/repo#<number>` key shape — never a
|
|
835
|
+
* GitHub URL. An epic probe is keyed by the durable lineage root (`owner/repo#N`, the epic issue's
|
|
836
|
+
* planKey, issue #568), and the lineage read-model stores roots in exactly that shape: admitting a
|
|
837
|
+
* URL here (via `parsePr`'s wider grammar) would let `probeOnce` query `/lineage?root=<the URL>`,
|
|
838
|
+
* which matches no root, so the probe could only time out instead of failing loudly at submission
|
|
839
|
+
* (#857). Derived from the canonical {@link parsePr} (no second grammar): a URL-shaped input parses
|
|
840
|
+
* fine there, so it is rejected by shape, not by a stricter regex. */
|
|
841
|
+
export function parsePlanKey(target: string): { planKey: string } | null {
|
|
842
|
+
const pr = parsePr(target);
|
|
843
|
+
// Canonical-key discriminator: `pr.prKey === target.trim()` proves the value ROUND-TRIPS through
|
|
844
|
+
// `parsePr` to EXACTLY the submitted string, which only the bare `owner/repo#N` shorthand does — a
|
|
845
|
+
// URL parses to a *different* prKey (`…/pull/7` → `o/r#7`), so it is rejected by shape. No extra
|
|
846
|
+
// slash-count check is needed: the shorthand branch's repo capture is now exactly one `/`
|
|
847
|
+
// (`[^/#]+/[^/#]+`, #857 review), so a URL-shaped/multi-slash value no longer parses at all and the
|
|
848
|
+
// round-trip test alone is the discriminator. The legitimate `owner/github.com#42` (a repo
|
|
849
|
+
// literally named `github.com`, one slash) still round-trips.
|
|
850
|
+
return pr && pr.prKey === target.trim() ? { planKey: pr.prKey } : null;
|
|
829
851
|
}
|
|
830
852
|
|
|
831
853
|
/** Build the `gh pr view` command that reads a PR's merge-state fields. `gh` reads its token from the
|
|
@@ -957,12 +979,16 @@ export async function probeOnce(
|
|
|
957
979
|
// "Fully merged" is the app's AGGREGATE, not a GitHub read — observe it over the app's own
|
|
958
980
|
// lineage read-model (level-triggered, same poll machinery as `pr`). A fact-bound target that is
|
|
959
981
|
// still unresolved (`<node>.<fact>`) is not a literal planKey, so treat it as "not ready" and
|
|
960
|
-
// keep waiting rather than issue a malformed request.
|
|
961
|
-
|
|
962
|
-
|
|
982
|
+
// keep waiting rather than issue a malformed request. Key the read (and the thread match) by the
|
|
983
|
+
// CANONICAL planKey from {@link parsePlanKey} — the shape lineage roots are stored under — never
|
|
984
|
+
// the raw target string, so a padded literal cannot silently miss every thread and burn the wait
|
|
985
|
+
// budget, and a URL-shaped value is rejected here rather than queried as a root (#857).
|
|
986
|
+
const key = parsePlanKey(probe.target);
|
|
987
|
+
if (!key) return { ready: false, detail: "epic: unresolved/unparseable planKey (not ready)" };
|
|
988
|
+
const url = epicLineageUrl(key.planKey, readEnvOr("NANO_WORKFORCE_BASE_URL", "", env));
|
|
963
989
|
const resp = await exec.httpGet(url, { accept: "application/json" });
|
|
964
990
|
if (resp.status < 200 || resp.status >= 300) return { ready: false, detail: `epic: lineage read HTTP ${resp.status} (not ready)` };
|
|
965
|
-
return matchEpic(probe.match, parseEpicLineage(parseJson(resp.body),
|
|
991
|
+
return matchEpic(probe.match, parseEpicLineage(parseJson(resp.body), key.planKey));
|
|
966
992
|
}
|
|
967
993
|
}
|
|
968
994
|
}
|
package/app/service.test.ts
CHANGED
|
@@ -1565,6 +1565,61 @@ test("parsePr still resolves a well-formed prKey and PR URL", () => {
|
|
|
1565
1565
|
assertEquals(parsePr("https://github.com/owner/repo/pull/7")?.repo, "owner/repo");
|
|
1566
1566
|
});
|
|
1567
1567
|
|
|
1568
|
+
// #857 (Copilot "Previously missed" advisory, app/readiness.ts:829/852): the canonical PR-URL
|
|
1569
|
+
// branch's host regex was UNANCHORED, so `github.com` matched as a substring of another host
|
|
1570
|
+
// (`notgithub.com`), as userinfo of a different host (`github.com@evil.com`), or anywhere inside a
|
|
1571
|
+
// prose-wrapped string — the pr/epic probe would then silently poll the embedded `owner/repo`
|
|
1572
|
+
// (a DIFFERENT target than the submitted value) or time out. Fail CLOSED on every non-canonical
|
|
1573
|
+
// host/wrap; anchoring the ONE grammar fixes both `parsePrTarget` and `parsePlanKey` at once.
|
|
1574
|
+
test("#857 parsePr REJECTS a spoofed/non-github host, userinfo trick, or prose-wrapped URL (anchored)", () => {
|
|
1575
|
+
for (const bad of [
|
|
1576
|
+
"https://notgithub.com/o/r/pull/7",
|
|
1577
|
+
"https://evilgithub.com/o/r/pull/7",
|
|
1578
|
+
"https://github.com.evil.com/o/r/pull/7",
|
|
1579
|
+
"https://github.com@evil.com/o/r/pull/7",
|
|
1580
|
+
"see https://github.com/o/r/pull/7",
|
|
1581
|
+
"https://github.com/o/r/pull/7 and more text",
|
|
1582
|
+
"ftp://github.com/o/r/pull/7",
|
|
1583
|
+
]) {
|
|
1584
|
+
assertEquals(parsePr(bad), null, `expected null for ${JSON.stringify(bad)}`);
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
test("#857 parsePr still ACCEPTS canonical GitHub PR URLs incl. supported suffixes", () => {
|
|
1589
|
+
assertEquals(parsePr("https://github.com/owner/repo/pull/7")?.prKey, "owner/repo#7");
|
|
1590
|
+
assertEquals(parsePr("github.com/owner/repo/pull/7")?.prKey, "owner/repo#7");
|
|
1591
|
+
assertEquals(parsePr("https://www.github.com/owner/repo/pull/7")?.prKey, "owner/repo#7");
|
|
1592
|
+
assertEquals(parsePr("https://github.com/owner/repo/pull/7/files")?.prKey, "owner/repo#7");
|
|
1593
|
+
assertEquals(parsePr("https://github.com/owner/repo/pull/7?diff=split")?.prKey, "owner/repo#7");
|
|
1594
|
+
assertEquals(parsePr("https://github.com/owner/repo/pull/7#issuecomment-1")?.prKey, "owner/repo#7");
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
// #857 (Copilot review, app/prParse.ts): the SHORTHAND branch's repo capture (`[^/]+/[^#]+`) admitted
|
|
1598
|
+
// EXTRA `/` characters, so a URL-shaped value (`https://example.com/o/r#7`) or a multi-slash path
|
|
1599
|
+
// (`a/b/c#9`) parsed to a "PR" whose repo is the whole multi-slash string — and readiness/worker
|
|
1600
|
+
// callers treat this parser as canonical, so that malformed target reached `gh` and polled the wrong
|
|
1601
|
+
// repository. The documented grammar is EXACTLY `owner/repo#N` (one slash) or a GitHub PR URL — fail
|
|
1602
|
+
// closed on every extra-slash impersonator. This also retires the slash-count discriminator
|
|
1603
|
+
// `parsePlanKey` carried (app/readiness.ts): the grammar now IS one-slash.
|
|
1604
|
+
test("#857 parsePr REJECTS a shorthand whose repo carries extra slashes (exactly owner/repo#N)", () => {
|
|
1605
|
+
for (const bad of [
|
|
1606
|
+
"https://example.com/o/r#7", // the exact advisory case — a non-GitHub URL via the shorthand branch
|
|
1607
|
+
"https://github.com/o/r#7", // a GitHub URL that is NOT the /pull/<n> spelling
|
|
1608
|
+
"//host/o/r#7", // scheme-relative URL-shaped
|
|
1609
|
+
"a/b/c#9", // a multi-slash path is not owner/repo
|
|
1610
|
+
"a/b/c/d#9",
|
|
1611
|
+
]) {
|
|
1612
|
+
assertEquals(parsePr(bad), null, `expected null for ${JSON.stringify(bad)}`);
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
test("#857 parsePr still ACCEPTS the canonical one-slash shorthand (incl. a repo literally named github.com)", () => {
|
|
1617
|
+
assertEquals(parsePr("owner/repo#7")?.prKey, "owner/repo#7");
|
|
1618
|
+
// A repo literally named `github.com` is ONE slash — the grammar keys on the slash count, not the
|
|
1619
|
+
// host substring, so this legitimate shorthand is not collateral damage (parseIssue relies on it).
|
|
1620
|
+
assertEquals(parsePr("owner/github.com#42")?.prKey, "owner/github.com#42");
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1568
1623
|
// Red/green regression for the level-triggered wave-merge barrier (issue #262). The barrier is
|
|
1569
1624
|
// armed (`plans.gate_wave = W`) at wave handoff, long BEFORE the token traverses the slow
|
|
1570
1625
|
// `trial-merge` agent job and finally opens the `wait-wave-merged` subscription. The old
|
package/docs/agent-guide.md
CHANGED
|
@@ -564,10 +564,33 @@ layer schedules, it does not re-implement execution):
|
|
|
564
564
|
|
|
565
565
|
| kind | config | what it does | may `emits`? |
|
|
566
566
|
|---|---|---|---|
|
|
567
|
-
| `agent` | `agent: { jobType, prompt?, converge?, merge? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** First-class **`converge?` / `merge?`** cell policy (§9.4) — a **declared, compiler-validated** completion-policy flag that *declares* review-convergence / landing intent (`merge` requires `converge`); a raw `senior:converge`/`senior:merge` job is rejected. This slice adds + validates the flags; the delivery-graph execution wiring that consumes them lands in a follow-up slice. | yes |
|
|
567
|
+
| `agent` | `agent: { jobType, repository?, baseBranch?, prompt?, converge?, merge?, timeout? }` | a worker runs an agent job type (the fan-out body) in a fresh checkout of its `repository` (see *Provision every agent node* below). **Side-effecting.** First-class **`converge?` / `merge?`** cell policy (§9.4) — a **declared, compiler-validated** completion-policy flag that *declares* review-convergence / landing intent (`merge` requires `converge`); a raw `senior:converge`/`senior:merge` job is rejected. This slice adds + validates the flags; the delivery-graph execution wiring that consumes them lands in a follow-up slice. **`timeout?`** is an OPTIONAL per-node ISO-8601 SLA (#505) that overrides the run-level `nodeTimeout` (and the `PT1H` default) for this node's bounded-timeout → escalate boundary, so a legitimately-long node outlasts a quick gate without a spurious escalation. | yes |
|
|
568
568
|
| `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`, `epic`. Read-only. | yes (binds observed facts) |
|
|
569
569
|
| `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
|
|
570
|
-
| `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Three **real targets** ship today — **`converge`**, **`converge-merge`** (unit → base branch) and **`merge-main`** (graph → `main`, the two-level top-level land) (§9.4); other targets are a forward-declared stub. | yes |
|
|
570
|
+
| `connector` | `connector: { target, dedupeKey?, payload?, timeout? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Three **real targets** ship today — **`converge`**, **`converge-merge`** (unit → base branch) and **`merge-main`** (graph → `main`, the two-level top-level land) (§9.4); other targets are a forward-declared stub. **`timeout?`** — a per-node ISO-8601 SLA (#505), same semantics as the `agent` row. | yes |
|
|
571
|
+
|
|
572
|
+
> **Provision every `agent` node with a repository — compile will NOT catch it.** Each `agent` cell
|
|
573
|
+
> runs in an isolated checkout: the harness clones the node's **`repository`** (`owner/repo`, no
|
|
574
|
+
> `.git`/URL) at **`baseBranch`** and cuts `feat/<node.id>` off it (#739/#776). `baseBranch` resolves
|
|
575
|
+
> **per field** — the node's own, else the **run-level dispatch `baseBranch`**, else the repo default
|
|
576
|
+
> (an id yielding an ill-formed ref like `a..b`/`a.lock` makes the harness skip `branch.create`, so
|
|
577
|
+
> the agent cuts its own branch). A node with **no `repository`** is staged by compile **without a
|
|
578
|
+
> warning**, then — absent a **run-level fallback** supplied *at dispatch* — Dispatch fails with
|
|
579
|
+
> `N agent node(s) resolve to no repository (…)`. So:
|
|
580
|
+
>
|
|
581
|
+
> - **Declare `repository` on EVERY `agent` node** (and `baseBranch` when it is neither the run-level
|
|
582
|
+
> base nor the default): a single-repo graph repeats one value; a cross-repo graph names each node's
|
|
583
|
+
> own repo. Every repo a node's prompt changes must be that node's `repository`.
|
|
584
|
+
> - **`repoless` is a RUN-WIDE mode, not a per-node escape hatch** — it strips isolation from *every*
|
|
585
|
+
> node. Use it only when **every** `agent` node is checkout-less (pure research/comment work); if
|
|
586
|
+
> even one needs a checkout, provision repositories instead.
|
|
587
|
+
> - **Self-check before handing over the digest:** every `"kind": "agent"` node has a `repository`;
|
|
588
|
+
> if you leave some unset, tell the operator which, and that they must pick the run-level
|
|
589
|
+
> **repository + base-branch fallback** in the Dispatch form (the default mode rejects the graph).
|
|
590
|
+
>
|
|
591
|
+
> A **present** `repository`/`baseBranch` is validated at compile (`invalid-node-repository` /
|
|
592
|
+
> `invalid-node-base-branch`); an **absent `repository`** defers to dispatch (and can fail there); an
|
|
593
|
+
> **absent `baseBranch` is always valid** — it inherits the run-level base, else the repo default.
|
|
571
594
|
|
|
572
595
|
A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
|
|
573
596
|
intake uses): `{ kind, target, onTimeout?, match?, poll? }`, where `poll` is
|
|
@@ -720,12 +743,12 @@ publish and records the version → open+merge PR #303 (repo 3) consuming that v
|
|
|
720
743
|
"wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" },
|
|
721
744
|
"poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } },
|
|
722
745
|
{ "id": "undraft-merge-b", "kind": "agent",
|
|
723
|
-
"agent": { "jobType": "senior:feature", "converge": true, "merge": true, "prompt": "Take draft PR acme/repo-2#202 out of draft; converge it to green and land it." } },
|
|
746
|
+
"agent": { "jobType": "senior:feature", "repository": "acme/repo-2", "converge": true, "merge": true, "prompt": "Take draft PR acme/repo-2#202 out of draft; converge it to green and land it." } },
|
|
724
747
|
{ "id": "manual-publish", "kind": "human",
|
|
725
748
|
"human": { "prompt": "Run the manual OTP-authenticated `npm publish` for @acme/widget and set up OIDC trusted publishing. Record the exact published version." },
|
|
726
749
|
"emits": [ { "name": "publishedVersion", "type": "version", "description": "The version just published to npm." } ] },
|
|
727
750
|
{ "id": "open-pr-c", "kind": "agent",
|
|
728
|
-
"agent": { "jobType": "senior:feature", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open PR #303." } },
|
|
751
|
+
"agent": { "jobType": "senior:feature", "repository": "acme/repo-3", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open PR #303." } },
|
|
729
752
|
{ "id": "merge-c", "kind": "wait",
|
|
730
753
|
"wait": { "kind": "pr", "target": "acme/repo-3#303", "match": { "prState": "merged" },
|
|
731
754
|
"poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } }
|
|
@@ -804,7 +827,7 @@ author never knows the PR number at compose time, so reference it by fact:
|
|
|
804
827
|
"name": "open → converge+merge → wait merged",
|
|
805
828
|
"nodes": [
|
|
806
829
|
{ "id": "open", "kind": "agent",
|
|
807
|
-
"agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." },
|
|
830
|
+
"agent": { "jobType": "senior:feature", "repository": "acme/repo", "prompt": "Implement the change in acme/repo and open a PR." },
|
|
808
831
|
"emits": [ { "name": "pr", "type": "pr" } ] },
|
|
809
832
|
{ "id": "land", "kind": "connector",
|
|
810
833
|
"connector": { "target": "converge-merge", "payload": { "pr": "open.pr" } } },
|
|
@@ -870,7 +893,7 @@ babysitting a `confirm` gate.
|
|
|
870
893
|
"onTimeout": "escalate" },
|
|
871
894
|
"emits": [ { "name": "prCount", "type": "number" } ] },
|
|
872
895
|
{ "id": "start-b", "kind": "agent",
|
|
873
|
-
"agent": { "jobType": "senior:feature", "prompt": "Implement nanobpm/nano-workforce#567 and open a PR." } }
|
|
896
|
+
"agent": { "jobType": "senior:feature", "repository": "nanobpm/nano-workforce", "prompt": "Implement nanobpm/nano-workforce#567 and open a PR." } }
|
|
874
897
|
],
|
|
875
898
|
"edges": [ { "from": "gate-epic", "to": "start-b" } ]
|
|
876
899
|
}
|
package/openapi.yaml
CHANGED
|
@@ -1959,7 +1959,7 @@ components:
|
|
|
1959
1959
|
target:
|
|
1960
1960
|
type: string
|
|
1961
1961
|
minLength: 1
|
|
1962
|
-
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue #548/#570); this rewrite exists ONLY on the delivery-graph dispatch path — other surfaces (e.g. feature-intake readiness) have no such resolver, so they must supply a literal handle.
|
|
1962
|
+
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference OR a GitHub PR URL (`https://github.com/owner/repo/pull/123`) for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue #548/#570); this rewrite exists ONLY on the delivery-graph dispatch path — other surfaces (e.g. feature-intake readiness) have no such resolver, so they must supply a literal handle.
|
|
1963
1963
|
onTimeout:
|
|
1964
1964
|
type: string
|
|
1965
1965
|
enum: [escalate, fail, continue]
|
|
@@ -2025,6 +2025,7 @@ components:
|
|
|
2025
2025
|
kind: agent
|
|
2026
2026
|
agent:
|
|
2027
2027
|
jobType: senior:feature
|
|
2028
|
+
repository: nanobpm/nano-workforce
|
|
2028
2029
|
prompt: Implement nanobpm/nano-workforce#567 and open a PR.
|
|
2029
2030
|
edges:
|
|
2030
2031
|
- { from: gate-epic, to: start-b }
|
|
@@ -2406,7 +2407,7 @@ components:
|
|
|
2406
2407
|
# cockpit paste). `previewDeliveryGraph` takes `{ "graphJson": "<serialized DeliveryGraph>" }`.
|
|
2407
2408
|
example:
|
|
2408
2409
|
graphJson: >-
|
|
2409
|
-
{"name":"start #567 once epic #488 has fully merged","nodes":[{"id":"gate-epic","kind":"wait","wait":{"kind":"epic","target":"nanobpm/nano-ide#488","match":{"epicState":"merged"},"poll":{"everyMs":300000,"timeoutMs":259200000},"onTimeout":"escalate"},"emits":[{"name":"prCount","type":"number"}]},{"id":"start-b","kind":"agent","agent":{"jobType":"senior:feature","prompt":"Implement nanobpm/nano-workforce#567 and open a PR."}}],"edges":[{"from":"gate-epic","to":"start-b"}]}
|
|
2410
|
+
{"name":"start #567 once epic #488 has fully merged","nodes":[{"id":"gate-epic","kind":"wait","wait":{"kind":"epic","target":"nanobpm/nano-ide#488","match":{"epicState":"merged"},"poll":{"everyMs":300000,"timeoutMs":259200000},"onTimeout":"escalate"},"emits":[{"name":"prCount","type":"number"}]},{"id":"start-b","kind":"agent","agent":{"jobType":"senior:feature","repository":"nanobpm/nano-workforce","prompt":"Implement nanobpm/nano-workforce#567 and open a PR."}}],"edges":[{"from":"gate-epic","to":"start-b"}]}
|
|
2410
2411
|
required:
|
|
2411
2412
|
- graphJson
|
|
2412
2413
|
properties:
|
|
@@ -5016,6 +5017,7 @@ paths:
|
|
|
5016
5017
|
kind: agent
|
|
5017
5018
|
agent:
|
|
5018
5019
|
jobType: senior:feature
|
|
5020
|
+
repository: nanobpm/nano-workforce
|
|
5019
5021
|
prompt: Implement nanobpm/nano-workforce#567 and open a PR.
|
|
5020
5022
|
edges:
|
|
5021
5023
|
- from: gate-epic
|
|
@@ -5176,7 +5178,7 @@ paths:
|
|
|
5176
5178
|
target:
|
|
5177
5179
|
type: string
|
|
5178
5180
|
minLength: 1
|
|
5179
|
-
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue
|
|
5181
|
+
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference OR a GitHub PR URL (`https://github.com/owner/repo/pull/123`) for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue
|
|
5180
5182
|
onTimeout:
|
|
5181
5183
|
type: string
|
|
5182
5184
|
enum:
|
|
@@ -5467,7 +5469,7 @@ paths:
|
|
|
5467
5469
|
type: object
|
|
5468
5470
|
additionalProperties: false
|
|
5469
5471
|
example:
|
|
5470
|
-
graphJson: '{"name":"start #567 once epic #488 has fully merged","nodes":[{"id":"gate-epic","kind":"wait","wait":{"kind":"epic","target":"nanobpm/nano-ide#488","match":{"epicState":"merged"},"poll":{"everyMs":300000,"timeoutMs":259200000},"onTimeout":"escalate"},"emits":[{"name":"prCount","type":"number"}]},{"id":"start-b","kind":"agent","agent":{"jobType":"senior:feature","prompt":"Implement nanobpm/nano-workforce#567 and open a PR."}}],"edges":[{"from":"gate-epic","to":"start-b"}]}'
|
|
5472
|
+
graphJson: '{"name":"start #567 once epic #488 has fully merged","nodes":[{"id":"gate-epic","kind":"wait","wait":{"kind":"epic","target":"nanobpm/nano-ide#488","match":{"epicState":"merged"},"poll":{"everyMs":300000,"timeoutMs":259200000},"onTimeout":"escalate"},"emits":[{"name":"prCount","type":"number"}]},{"id":"start-b","kind":"agent","agent":{"jobType":"senior:feature","repository":"nanobpm/nano-workforce","prompt":"Implement nanobpm/nano-workforce#567 and open a PR."}}],"edges":[{"from":"gate-epic","to":"start-b"}]}'
|
|
5471
5473
|
required:
|
|
5472
5474
|
- graphJson
|
|
5473
5475
|
properties:
|
|
@@ -5876,10 +5878,10 @@ paths:
|
|
|
5876
5878
|
- kind: agent
|
|
5877
5879
|
configKey: agent
|
|
5878
5880
|
requiredFields: [jobType]
|
|
5879
|
-
optionalFields: [prompt, converge, merge]
|
|
5881
|
+
optionalFields: [repository, baseBranch, prompt, converge, merge, timeout]
|
|
5880
5882
|
sideEffecting: true
|
|
5881
5883
|
mayEmit: true
|
|
5882
|
-
summary: "A worker runs an agent job type
|
|
5884
|
+
summary: "A worker runs an agent job type in an isolated checkout; declare `repository` (`owner/repo`) on every agent node — the harness clones it at `baseBranch` (else the run-level dispatch base, else the repo default). Optional per-node ISO-8601 `timeout`, plus `converge?`/`merge?` cell-policy flags. An agent that opens a PR emits it as a `pr` fact."
|
|
5883
5885
|
- kind: wait
|
|
5884
5886
|
configKey: wait
|
|
5885
5887
|
requiredFields: [kind, target]
|
|
@@ -5890,7 +5892,7 @@ paths:
|
|
|
5890
5892
|
- kind: connector
|
|
5891
5893
|
configKey: connector
|
|
5892
5894
|
requiredFields: [target]
|
|
5893
|
-
optionalFields: [dedupeKey, payload]
|
|
5895
|
+
optionalFields: [dedupeKey, payload, timeout]
|
|
5894
5896
|
sideEffecting: true
|
|
5895
5897
|
mayEmit: true
|
|
5896
5898
|
summary: "An automated outbound action; payload for a converge target is { pr, autoMerge?, dependsOn? }."
|
|
@@ -6228,7 +6230,7 @@ paths:
|
|
|
6228
6230
|
target:
|
|
6229
6231
|
type: string
|
|
6230
6232
|
minLength: 1
|
|
6231
|
-
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue
|
|
6233
|
+
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference OR a GitHub PR URL (`https://github.com/owner/repo/pull/123`) for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue
|
|
6232
6234
|
onTimeout:
|
|
6233
6235
|
type: string
|
|
6234
6236
|
enum:
|
|
@@ -6376,7 +6378,7 @@ paths:
|
|
|
6376
6378
|
target:
|
|
6377
6379
|
type: string
|
|
6378
6380
|
minLength: 1
|
|
6379
|
-
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue
|
|
6381
|
+
description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference OR a GitHub PR URL (`https://github.com/owner/repo/pull/123`) for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue
|
|
6380
6382
|
onTimeout:
|
|
6381
6383
|
type: string
|
|
6382
6384
|
enum:
|
|
@@ -61,10 +61,15 @@ test("the guide documents the delivery-graph surface (ADR 0005)", async () => {
|
|
|
61
61
|
// The closed node vocabulary: assert the exact config snippet for each of the four kinds,
|
|
62
62
|
// so the test fails if §9's node-kind table is removed or reworded — not merely if the bare
|
|
63
63
|
// words "agent"/"wait"/"human"/"connector" appear anywhere else in the guide.
|
|
64
|
-
assert(md.includes("agent: { jobType, prompt?, converge?, merge? }"), "documents the agent node config");
|
|
64
|
+
assert(md.includes("agent: { jobType, repository?, baseBranch?, prompt?, converge?, merge?, timeout? }"), "documents the agent node config");
|
|
65
|
+
// Per-node repository provisioning is only enforced at DISPATCH (a run-level fallback can satisfy it),
|
|
66
|
+
// so compile stages an unprovisioned graph silently — the guide must tell the authoring agent to
|
|
67
|
+
// declare `repository` on every agent node, or the operator's Dispatch fails out of the agent's loop.
|
|
68
|
+
assert(md.includes("Provision every `agent` node with a repository"), "documents per-node repository provisioning");
|
|
69
|
+
assert(md.includes("resolve to no repository"), "quotes the dispatch-time failure the agent must pre-empt");
|
|
65
70
|
assert(md.includes("wait: <ReadinessProbe>"), "documents the wait node config");
|
|
66
71
|
assert(md.includes("human?: { formKey?, prompt? }"), "documents the human node config");
|
|
67
|
-
assert(md.includes("connector: { target, dedupeKey?, payload? }"), "documents the connector node config");
|
|
72
|
+
assert(md.includes("connector: { target, dedupeKey?, payload?, timeout? }"), "documents the connector node config");
|
|
68
73
|
// The fact-edge syntax: an edge is `{ from, to }` and `from` may be a qualified `<nodeId>.<fact>`.
|
|
69
74
|
assert(md.includes("each edge is `{ from, to }`"), "documents the edge shape");
|
|
70
75
|
assert(md.includes("qualified `<nodeId>.<fact>`"), "documents the qualified fact-edge syntax");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.200.
|
|
3
|
+
"version": "0.200.3",
|
|
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",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@biomejs/biome": "^2.4.11",
|
|
73
73
|
"@nanobpm/urban-testkit": "^1.4.0",
|
|
74
|
-
"@nanobpm/workflow": "^0.
|
|
74
|
+
"@nanobpm/workflow": "^0.15.0",
|
|
75
75
|
"@semantic-release/changelog": "^7.0.0",
|
|
76
76
|
"@semantic-release/git": "^11.0.0",
|
|
77
77
|
"@semantic-release/npm": "^13.1.5",
|
|
@@ -60,3 +60,35 @@ test("the served payload matches the response schema's required keys (data ⇄ c
|
|
|
60
60
|
assert(key in payload, `served vocabulary is missing required schema key '${key}'`);
|
|
61
61
|
}
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
test("the worked example's nodeKind field contract cannot drift from the runtime vocabulary", () => {
|
|
65
|
+
// The spec's `example:` is a hand-authored YAML copy of the served payload (the runtime cannot be
|
|
66
|
+
// called from a static spec). It is a deliberately curated SUBSET (fewer kinds / terser summaries),
|
|
67
|
+
// but the per-kind FIELD CONTRACT it advertises is consumer-facing: a stale `optionalFields` makes
|
|
68
|
+
// agents author nodes that omit real fields (#739/#776 — `repository`/`baseBranch`/`timeout`), and a
|
|
69
|
+
// stale `configKey`/`sideEffecting`/`mayEmit` misleads them the same way. Guard the class, not one
|
|
70
|
+
// field: every nodeKind the example shows must carry EXACTLY the runtime kind's whole contract object
|
|
71
|
+
// — `kind`, `configKey`, `requiredFields`, `optionalFields`, `sideEffecting`, `mayEmit` — so no part
|
|
72
|
+
// of the two surfaces can drift apart again. Only `summary` is excluded: the example's is a
|
|
73
|
+
// deliberately terser curated paraphrase of the runtime's (see this block's preamble).
|
|
74
|
+
const doc = parseYaml(SPEC_TEXT) as Record<string, any>;
|
|
75
|
+
const exampleKinds = doc.paths["/delivery-graph/vocabulary"].get.responses["200"].content["application/json"].schema
|
|
76
|
+
.example?.nodeKinds as Array<Record<string, unknown>> | undefined;
|
|
77
|
+
assert(Array.isArray(exampleKinds) && exampleKinds.length > 0, "the worked example must list nodeKinds");
|
|
78
|
+
|
|
79
|
+
// Compare the full per-kind contract minus `summary` (the sole intentionally-divergent field).
|
|
80
|
+
const contract = ({ summary: _summary, ...rest }: Record<string, unknown>): Record<string, unknown> => rest;
|
|
81
|
+
|
|
82
|
+
const runtimeByKind = new Map(
|
|
83
|
+
(deliveryGraphVocabulary().nodeKinds as Array<Record<string, unknown>>).map((k) => [k.kind, k]),
|
|
84
|
+
);
|
|
85
|
+
for (const shown of exampleKinds) {
|
|
86
|
+
const runtime = runtimeByKind.get(shown.kind);
|
|
87
|
+
assert(runtime, `example nodeKind '${String(shown.kind)}' is not a real runtime kind (stale/removed)`);
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
contract(shown),
|
|
90
|
+
contract(runtime!),
|
|
91
|
+
`example nodeKind '${String(shown.kind)}' field contract (configKey/requiredFields/optionalFields/sideEffecting/mayEmit) drifted from runtime`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|