@nanobpm/nano-workforce 0.172.1 → 0.174.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/github.test.ts +2 -0
- package/app/github.ts +113 -1
- package/app/mergeQueueEviction.test.ts +223 -0
- package/app/queuedVerdict.test.ts +38 -2
- package/app/readiness.test.ts +1 -0
- package/app/readiness.ts +1 -0
- package/app/service.ts +37 -18
- package/docs/mcp-runbook.md +22 -9
- package/package.json +1 -1
- package/pages/_nav.json +1 -0
- package/pages/board.page.json +4 -0
- package/pages/cockpit.page.json +4 -0
- package/pages/delivery-graph-detail.page.json +4 -0
- package/pages/delivery-graphs.page.json +4 -0
- package/pages/epic-detail.page.json +4 -0
- package/pages/epic.page.json +4 -0
- package/pages/feature.page.json +4 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +4 -0
- package/pages/mcp.page.json +217 -0
- package/pages/overview.page.json +4 -0
- package/pages/tasks.page.json +40 -9
- package/pages/velocity.page.json +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.174.0](https://github.com/nanobpm/nano-workforce/compare/v0.173.0...v0.174.0) (2026-09-02)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **merge-loop:** auto-recover PRs evicted from the merge queue by CI failure, not just conflicts ([#703](https://github.com/nanobpm/nano-workforce/issues/703)) ([45a3051](https://github.com/nanobpm/nano-workforce/commit/45a3051e37767195c75940347af7197f999c3d30)), closes [#556](https://github.com/nanobpm/nano-workforce/issues/556) [#702](https://github.com/nanobpm/nano-workforce/issues/702)
|
|
6
|
+
|
|
7
|
+
## [0.173.0](https://github.com/nanobpm/nano-workforce/compare/v0.172.1...v0.173.0) (2026-09-01)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **mcp:** add a deployed "Connect over MCP" console page ([#701](https://github.com/nanobpm/nano-workforce/issues/701)) ([dd2b998](https://github.com/nanobpm/nano-workforce/commit/dd2b99861ec4e2c8d46fc7e69397ac71954cf27c)), closes [#698](https://github.com/nanobpm/nano-workforce/issues/698) [#698](https://github.com/nanobpm/nano-workforce/issues/698) [#699](https://github.com/nanobpm/nano-workforce/issues/699)
|
|
12
|
+
|
|
1
13
|
## [0.172.1](https://github.com/nanobpm/nano-workforce/compare/v0.172.0...v0.172.1) (2026-09-01)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/github.test.ts
CHANGED
|
@@ -446,6 +446,7 @@ function prState(over: Partial<PrState>): PrState {
|
|
|
446
446
|
totalChecks: 0,
|
|
447
447
|
isDraft: false,
|
|
448
448
|
headRefOid: null,
|
|
449
|
+
mergeQueueEntry: null,
|
|
449
450
|
...over,
|
|
450
451
|
};
|
|
451
452
|
}
|
|
@@ -493,6 +494,7 @@ function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclu
|
|
|
493
494
|
checkConclusions: {},
|
|
494
495
|
isDraft: false,
|
|
495
496
|
headRefOid: "abc123",
|
|
497
|
+
mergeQueueEntry: null,
|
|
496
498
|
};
|
|
497
499
|
if (rollup) {
|
|
498
500
|
const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
|
package/app/github.ts
CHANGED
|
@@ -522,6 +522,20 @@ export interface PrState {
|
|
|
522
522
|
isDraft: boolean;
|
|
523
523
|
/** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
|
|
524
524
|
headRefOid: string | null;
|
|
525
|
+
/** GROUND-TRUTH native-merge-queue membership, populated only when `fetchPrState` is called with
|
|
526
|
+
* `{ withMergeQueue: true }` (the block-4 queued-PR reconciliation) — otherwise `null`. It lets the
|
|
527
|
+
* poller OBSERVE a queue eviction instead of inferring "still queued" from `mergeStateStatus`
|
|
528
|
+
* alone (which cannot see a CI-on-`merge_group` eviction — the head reverts to BLOCKED/UNSTABLE/
|
|
529
|
+
* CLEAN, never DIRTY). Tri-state:
|
|
530
|
+
* • `true` — the PR is currently enrolled in the repo's native GitHub merge queue.
|
|
531
|
+
* • `false` — the base branch HAS a native merge queue but the PR is NO LONGER in it (a genuine
|
|
532
|
+
* eviction: CI failed on the speculative `merge_group` commit, the base moved, or a manual
|
|
533
|
+
* dequeue). `queuedVerdict` turns this into `evicted` → `arm-merge` re-drives the mergeable gate.
|
|
534
|
+
* • `null` — indeterminate: not probed, no usable transport, a transport error, OR the base
|
|
535
|
+
* branch has no native merge queue at all (a Mergify/plain-merge repo — see #556). A
|
|
536
|
+
* perpetually-null entry on such a repo must NOT read as an eviction, so the classifier stays
|
|
537
|
+
* conservative and leaves the `landedWaitTimeout` human backstop to cover a never-lands wedge. */
|
|
538
|
+
mergeQueueEntry: boolean | null;
|
|
525
539
|
}
|
|
526
540
|
|
|
527
541
|
/** Map GitHub's REST `mergeable_state` (lower-case) onto the GraphQL `mergeStateStatus`
|
|
@@ -686,10 +700,96 @@ export function isNotAPullRequestError(err: unknown): boolean {
|
|
|
686
700
|
return /could not resolve to a pullrequest/i.test(msg) || /\bgithub 404\b/i.test(msg);
|
|
687
701
|
}
|
|
688
702
|
|
|
703
|
+
/** GraphQL response for the merge-queue membership probe. Both transports (`gh api graphql` and the
|
|
704
|
+
* raw GraphQL endpoint) wrap the payload in a top-level `data`. */
|
|
705
|
+
interface MergeQueueMembershipResponse {
|
|
706
|
+
data?: {
|
|
707
|
+
repository?: {
|
|
708
|
+
mergeQueue?: { id?: string } | null;
|
|
709
|
+
pullRequest?: { mergeQueueEntry?: { id?: string } | null } | null;
|
|
710
|
+
} | null;
|
|
711
|
+
} | null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Read GROUND-TRUTH native-merge-queue membership for a PR the merge loop enqueued (parked at
|
|
715
|
+
* `wait-landed`), so an eviction is OBSERVED rather than inferred from `mergeStateStatus` (which
|
|
716
|
+
* cannot see a CI-on-`merge_group` eviction — the head reverts to BLOCKED/UNSTABLE/CLEAN, never
|
|
717
|
+
* DIRTY). Returns:
|
|
718
|
+
* • `true` — the PR is currently enrolled in the base branch's native GitHub merge queue.
|
|
719
|
+
* • `false` — the base branch HAS a native merge queue but the PR is NO LONGER in it (a genuine
|
|
720
|
+
* eviction: CI failed on the speculative `merge_group` commit, the base moved, or a manual
|
|
721
|
+
* dequeue).
|
|
722
|
+
* • `null` — indeterminate: no usable transport / a transport error, OR the base branch has no
|
|
723
|
+
* native merge queue at all (a Mergify/plain-merge repo whose "queued" classification came from
|
|
724
|
+
* an ambiguous signal — #556). We gate on `repository.mergeQueue` existing first so a
|
|
725
|
+
* perpetually-null `mergeQueueEntry` on such a repo is never mistaken for an eviction; the
|
|
726
|
+
* `landedWaitTimeout` human backstop covers the genuinely-never-lands case there.
|
|
727
|
+
* Never throws — any failure degrades to `null` so the poller keeps waiting rather than falsely
|
|
728
|
+
* evicting a still-legitimately-queuing PR. */
|
|
729
|
+
export async function fetchMergeQueueMembership(
|
|
730
|
+
repo: string,
|
|
731
|
+
number: number | string,
|
|
732
|
+
baseBranch: string,
|
|
733
|
+
token: string,
|
|
734
|
+
): Promise<boolean | null> {
|
|
735
|
+
if (!baseBranch) return null; // can't scope `mergeQueue(branch:)` without the base ref → stay conservative
|
|
736
|
+
const [owner, name] = repo.split("/");
|
|
737
|
+
const query =
|
|
738
|
+
"query($o:String!,$r:String!,$n:Int!,$b:String!){repository(owner:$o,name:$r){" +
|
|
739
|
+
"mergeQueue(branch:$b){id}pullRequest(number:$n){mergeQueueEntry{id}}}}";
|
|
740
|
+
const mode = githubTransport();
|
|
741
|
+
const useGhHere = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
|
|
742
|
+
if (!useGhHere && !token) return null;
|
|
743
|
+
try {
|
|
744
|
+
let payload: MergeQueueMembershipResponse;
|
|
745
|
+
if (useGhHere) {
|
|
746
|
+
const out = await runGh([
|
|
747
|
+
"api",
|
|
748
|
+
"graphql",
|
|
749
|
+
"-f",
|
|
750
|
+
`query=${query}`,
|
|
751
|
+
"-f",
|
|
752
|
+
`o=${owner}`,
|
|
753
|
+
"-f",
|
|
754
|
+
`r=${name}`,
|
|
755
|
+
"-F",
|
|
756
|
+
`n=${number}`,
|
|
757
|
+
"-f",
|
|
758
|
+
`b=${baseBranch}`,
|
|
759
|
+
]);
|
|
760
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
761
|
+
payload = JSON.parse(out) as MergeQueueMembershipResponse;
|
|
762
|
+
} else {
|
|
763
|
+
const r = await fetch("https://api.github.com/graphql", {
|
|
764
|
+
method: "POST",
|
|
765
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
766
|
+
body: JSON.stringify({ query, variables: { o: owner, r: name, n: Number(number), b: baseBranch } }),
|
|
767
|
+
});
|
|
768
|
+
if (!r.ok) return null;
|
|
769
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
770
|
+
payload = (await r.json()) as MergeQueueMembershipResponse;
|
|
771
|
+
}
|
|
772
|
+
const repository = payload.data?.repository;
|
|
773
|
+
if (!repository) return null; // unreadable / GraphQL error → indeterminate
|
|
774
|
+
// No native merge queue on this base branch → an eviction is unobservable here (Mergify/plain).
|
|
775
|
+
if (!repository.mergeQueue) return null;
|
|
776
|
+
// A missing `pullRequest` (partial GraphQL `data` alongside `errors`, or an unreadable PR) is
|
|
777
|
+
// NOT an eviction — treat it as indeterminate so a transport hiccup can't thrash `arm-merge`.
|
|
778
|
+
const pr = repository.pullRequest;
|
|
779
|
+
if (pr == null) return null;
|
|
780
|
+
// Native queue exists and the PR is readable: enrolled iff it still carries a live queue entry.
|
|
781
|
+
return pr.mergeQueueEntry != null;
|
|
782
|
+
} catch {
|
|
783
|
+
// A transport/parse failure must not falsely evict — stay conservative.
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
689
788
|
export async function fetchPrState(
|
|
690
789
|
repo: string,
|
|
691
790
|
number: number | string,
|
|
692
791
|
token: string,
|
|
792
|
+
opts?: { withMergeQueue?: boolean },
|
|
693
793
|
): Promise<PrState | null> {
|
|
694
794
|
if (await useGh()) {
|
|
695
795
|
const out = await runGh([
|
|
@@ -699,7 +799,7 @@ export async function fetchPrState(
|
|
|
699
799
|
"--repo",
|
|
700
800
|
repo,
|
|
701
801
|
"--json",
|
|
702
|
-
"state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid",
|
|
802
|
+
"state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,baseRefName",
|
|
703
803
|
]);
|
|
704
804
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
705
805
|
const j = JSON.parse(out) as {
|
|
@@ -709,10 +809,16 @@ export async function fetchPrState(
|
|
|
709
809
|
statusCheckRollup?: RollupEntry[];
|
|
710
810
|
isDraft?: boolean;
|
|
711
811
|
headRefOid?: string | null;
|
|
812
|
+
baseRefName?: string | null;
|
|
712
813
|
};
|
|
713
814
|
const rollup = j.statusCheckRollup ?? [];
|
|
714
815
|
const names = failingCheckNames(rollup);
|
|
715
816
|
const merged = j.state === "MERGED" || !!j.mergedAt;
|
|
817
|
+
// Probe native-queue membership only for the queued-PR reconciliation (block 4) — it is an extra
|
|
818
|
+
// GraphQL round-trip, so every other caller leaves `mergeQueueEntry` null (unprobed).
|
|
819
|
+
const mergeQueueEntry = opts?.withMergeQueue
|
|
820
|
+
? await fetchMergeQueueMembership(repo, number, j.baseRefName ?? "", token)
|
|
821
|
+
: null;
|
|
716
822
|
return {
|
|
717
823
|
merged,
|
|
718
824
|
state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
|
|
@@ -725,6 +831,7 @@ export async function fetchPrState(
|
|
|
725
831
|
checkConclusions: checkConclusions(rollup),
|
|
726
832
|
isDraft: !!j.isDraft,
|
|
727
833
|
headRefOid: j.headRefOid ?? null,
|
|
834
|
+
mergeQueueEntry,
|
|
728
835
|
};
|
|
729
836
|
}
|
|
730
837
|
if (!token) return null;
|
|
@@ -740,8 +847,12 @@ export async function fetchPrState(
|
|
|
740
847
|
mergeable_state?: string;
|
|
741
848
|
draft?: boolean;
|
|
742
849
|
head?: { sha?: string | null };
|
|
850
|
+
base?: { ref?: string | null };
|
|
743
851
|
};
|
|
744
852
|
const restMerged = !!j.merged || !!j.merged_at;
|
|
853
|
+
const mergeQueueEntry = opts?.withMergeQueue
|
|
854
|
+
? await fetchMergeQueueMembership(repo, number, j.base?.ref ?? "", token)
|
|
855
|
+
: null;
|
|
745
856
|
return {
|
|
746
857
|
// The single-PR GET returns a `merged` boolean (unlike the list endpoint); we also honour
|
|
747
858
|
// `merged_at` so this mirrors the gh branch's `state === "MERGED" || mergedAt` rule.
|
|
@@ -758,6 +869,7 @@ export async function fetchPrState(
|
|
|
758
869
|
checkConclusions: {}, // …no per-check conclusions → protocol-aware gate falls through in token mode
|
|
759
870
|
isDraft: !!j.draft,
|
|
760
871
|
headRefOid: j.head?.sha ?? null,
|
|
872
|
+
mergeQueueEntry, // native-queue membership (GraphQL), probed only for block-4 reconciliation
|
|
761
873
|
};
|
|
762
874
|
}
|
|
763
875
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// #702: the merge-loop must auto-recover a PR EVICTED from the GitHub merge queue for ANY reason —
|
|
2
|
+
// not only a merge conflict (`DIRTY`). A PR dropped because required checks FAILED on the
|
|
3
|
+
// speculative `merge_group` commit (the ALLGREEN-batch invalidation) is NOT `DIRTY` — its head
|
|
4
|
+
// reverts to BLOCKED/UNSTABLE/CLEAN — so the old `mergeStateStatus`-only classifier kept it parked
|
|
5
|
+
// at `wait-landed` until the PT1H `landedWaitTimeout` escalated to a human. The poller now reads
|
|
6
|
+
// GROUND-TRUTH native-queue membership (GraphQL `mergeQueueEntry`) so a clean eviction publishes
|
|
7
|
+
// `merge-evicted` (→ `arm-merge` → the mergeable gate re-drives `fix-ci`/`rebase`).
|
|
8
|
+
//
|
|
9
|
+
// These are poller-level tests: they drive `pollMerges` over a `queued` PR row against a
|
|
10
|
+
// token-transport GitHub stub that serves BOTH the REST PR view and the GraphQL merge-queue probe.
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { assertEquals } from "#test-assert";
|
|
13
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
14
|
+
import { fetchMergeQueueMembership } from "./github.ts";
|
|
15
|
+
import { pollMerges } from "./service.ts";
|
|
16
|
+
|
|
17
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
18
|
+
const stores: Record<string, any[]> = {};
|
|
19
|
+
function tbl(name: string, pk = "id") {
|
|
20
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
21
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
22
|
+
return {
|
|
23
|
+
async all() {
|
|
24
|
+
return rows.slice();
|
|
25
|
+
},
|
|
26
|
+
async get(id: any) {
|
|
27
|
+
return rows.find((r) => r[pk] === id);
|
|
28
|
+
},
|
|
29
|
+
async find(where: any = {}) {
|
|
30
|
+
return rows.filter((r) => match(r, where));
|
|
31
|
+
},
|
|
32
|
+
async insert(row: any) {
|
|
33
|
+
rows.push({ ...row });
|
|
34
|
+
return row[pk];
|
|
35
|
+
},
|
|
36
|
+
async update(id: any, patch: any) {
|
|
37
|
+
const r = rows.find((row) => row[pk] === id);
|
|
38
|
+
if (r) Object.assign(r, patch);
|
|
39
|
+
},
|
|
40
|
+
async delete(id: any) {
|
|
41
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
46
|
+
return { data, stores };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function recordingEngine(): { engine: EngineClient; messages: any[] } {
|
|
50
|
+
const messages: any[] = [];
|
|
51
|
+
const engine = {
|
|
52
|
+
async publishMessage(msg: any) {
|
|
53
|
+
messages.push(msg);
|
|
54
|
+
},
|
|
55
|
+
} as any as EngineClient;
|
|
56
|
+
return { engine, messages };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A token-transport GitHub stub serving BOTH the REST `GET …/pulls/{n}` (the PR's merge state) and
|
|
60
|
+
// the GraphQL merge-queue membership probe. `mergeableState` is the head's `mergeable_state`;
|
|
61
|
+
// `hasQueue` says the base branch has a native merge queue; `enrolled` says the PR still holds a
|
|
62
|
+
// live `mergeQueueEntry`.
|
|
63
|
+
interface Fixture {
|
|
64
|
+
mergeableState: string; // e.g. "blocked" | "unstable" | "clean" | "dirty"
|
|
65
|
+
hasQueue: boolean;
|
|
66
|
+
enrolled: boolean;
|
|
67
|
+
}
|
|
68
|
+
function githubFetch(fx: Fixture) {
|
|
69
|
+
return (url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
70
|
+
const u = new URL(String(url));
|
|
71
|
+
const json = (obj: unknown, status = 200) =>
|
|
72
|
+
Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
|
|
73
|
+
if (u.pathname === "/graphql") {
|
|
74
|
+
return json({
|
|
75
|
+
data: {
|
|
76
|
+
repository: {
|
|
77
|
+
mergeQueue: fx.hasQueue ? { id: "MQ_1" } : null,
|
|
78
|
+
pullRequest: { mergeQueueEntry: fx.enrolled ? { id: "MQE_1" } : null },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const m = u.pathname.match(/\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/);
|
|
84
|
+
if (m) {
|
|
85
|
+
return json({
|
|
86
|
+
merged: false,
|
|
87
|
+
merged_at: null,
|
|
88
|
+
state: "open",
|
|
89
|
+
mergeable_state: fx.mergeableState,
|
|
90
|
+
draft: false,
|
|
91
|
+
head: { sha: "deadbeef" },
|
|
92
|
+
base: { ref: "main" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return Promise.resolve(new Response(`unexpected ${u.pathname}`, { status: 500 }));
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function withGithub<T>(fx: Fixture, fn: () => Promise<T>): Promise<T> {
|
|
100
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
101
|
+
const prevFetch = globalThis.fetch;
|
|
102
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
103
|
+
globalThis.fetch = githubFetch(fx) as typeof fetch;
|
|
104
|
+
try {
|
|
105
|
+
return await fn();
|
|
106
|
+
} finally {
|
|
107
|
+
globalThis.fetch = prevFetch;
|
|
108
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
109
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function queuedRow(prKey: string, number: number) {
|
|
114
|
+
const ts = "2026-08-20T00:00:00Z";
|
|
115
|
+
return {
|
|
116
|
+
pr_key: prKey,
|
|
117
|
+
repo: "o/r",
|
|
118
|
+
number,
|
|
119
|
+
url: `https://github.com/o/r/pull/${number}`,
|
|
120
|
+
title: "t",
|
|
121
|
+
status: "queued",
|
|
122
|
+
current_round: 0,
|
|
123
|
+
process_key: null,
|
|
124
|
+
waiting_since: null,
|
|
125
|
+
last_review_id: null,
|
|
126
|
+
outcome: null,
|
|
127
|
+
created_at: ts,
|
|
128
|
+
updated_at: ts,
|
|
129
|
+
converged_at: null,
|
|
130
|
+
merged_at: null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("#702 poller: a queued PR dropped from the queue (not DIRTY) publishes merge-evicted", async () => {
|
|
135
|
+
// The exact CI-on-merge_group eviction: head is BLOCKED (not DIRTY), native queue exists, but the
|
|
136
|
+
// PR is no longer enrolled. The old DIRTY-only classifier stayed silent here.
|
|
137
|
+
const { data, stores } = memData();
|
|
138
|
+
const { engine, messages } = recordingEngine();
|
|
139
|
+
stores["pull_requests"] = [queuedRow("o/r#100", 100)];
|
|
140
|
+
|
|
141
|
+
await withGithub({ mergeableState: "blocked", hasQueue: true, enrolled: false }, () =>
|
|
142
|
+
pollMerges(data, engine, "tok"),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
assertEquals(messages.length, 1, "an evicted queued PR must publish exactly one escape message");
|
|
146
|
+
assertEquals(messages[0].name, "merge-evicted");
|
|
147
|
+
assertEquals(messages[0].correlationKey, "o/r#100");
|
|
148
|
+
// Flipped onto the transient `merging` status so a slow next pass can't double-signal.
|
|
149
|
+
assertEquals(stores["pull_requests"][0].status, "merging");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("#702 regression: a queued PR still ENROLLED (BLOCKED pending queue check) stays parked", async () => {
|
|
153
|
+
const { data, stores } = memData();
|
|
154
|
+
const { engine, messages } = recordingEngine();
|
|
155
|
+
stores["pull_requests"] = [queuedRow("o/r#100", 100)];
|
|
156
|
+
|
|
157
|
+
await withGithub({ mergeableState: "blocked", hasQueue: true, enrolled: true }, () =>
|
|
158
|
+
pollMerges(data, engine, "tok"),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
assertEquals(messages.length, 0, "a still-enrolled queuing PR must not be falsely evicted");
|
|
162
|
+
assertEquals(stores["pull_requests"][0].status, "queued");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("#702: a repo with NO native merge queue (Mergify/plain) never falsely evicts a queued PR", async () => {
|
|
166
|
+
// `mergeQueueEntry` is perpetually null there — the #556 `landedWaitTimeout` backstop, not a false
|
|
167
|
+
// eviction, must cover a never-lands wedge. So the poller stays silent and the PR stays queued.
|
|
168
|
+
const { data, stores } = memData();
|
|
169
|
+
const { engine, messages } = recordingEngine();
|
|
170
|
+
stores["pull_requests"] = [queuedRow("o/r#100", 100)];
|
|
171
|
+
|
|
172
|
+
await withGithub({ mergeableState: "blocked", hasQueue: false, enrolled: false }, () =>
|
|
173
|
+
pollMerges(data, engine, "tok"),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
assertEquals(messages.length, 0, "no native queue → indeterminate membership → keep waiting");
|
|
177
|
+
assertEquals(stores["pull_requests"][0].status, "queued");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// #703 (suppressed-advisory follow-up): `fetchMergeQueueMembership` must stay conservative when the
|
|
181
|
+
// GraphQL payload carries a native `mergeQueue` but the `pullRequest` node is missing (partial
|
|
182
|
+
// `data` alongside `errors`, or an unreadable PR). A null `pullRequest` is INDETERMINATE (`null`),
|
|
183
|
+
// never a definitive eviction (`false`) — otherwise a transport hiccup would thrash `arm-merge`.
|
|
184
|
+
async function withProbeFetch<T>(body: unknown, fn: () => Promise<T>): Promise<T> {
|
|
185
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
186
|
+
const prevFetch = globalThis.fetch;
|
|
187
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
188
|
+
globalThis.fetch = ((): Promise<Response> =>
|
|
189
|
+
Promise.resolve(
|
|
190
|
+
new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }),
|
|
191
|
+
)) as typeof fetch;
|
|
192
|
+
try {
|
|
193
|
+
return await fn();
|
|
194
|
+
} finally {
|
|
195
|
+
globalThis.fetch = prevFetch;
|
|
196
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
197
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test("#703: a native queue with a MISSING pullRequest node reads indeterminate (null), not evicted", async () => {
|
|
202
|
+
const membership = await withProbeFetch(
|
|
203
|
+
{ data: { repository: { mergeQueue: { id: "MQ_1" }, pullRequest: null } } },
|
|
204
|
+
() => fetchMergeQueueMembership("o/r", 100, "main", "tok"),
|
|
205
|
+
);
|
|
206
|
+
assertEquals(membership, null, "missing pullRequest must be indeterminate, never a false eviction");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("#703: a native queue with a live pullRequest entry reads enrolled (true)", async () => {
|
|
210
|
+
const membership = await withProbeFetch(
|
|
211
|
+
{ data: { repository: { mergeQueue: { id: "MQ_1" }, pullRequest: { mergeQueueEntry: { id: "MQE_1" } } } } },
|
|
212
|
+
() => fetchMergeQueueMembership("o/r", 100, "main", "tok"),
|
|
213
|
+
);
|
|
214
|
+
assertEquals(membership, true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("#703: a native queue with a present PR but no entry reads a genuine eviction (false)", async () => {
|
|
218
|
+
const membership = await withProbeFetch(
|
|
219
|
+
{ data: { repository: { mergeQueue: { id: "MQ_1" }, pullRequest: { mergeQueueEntry: null } } } },
|
|
220
|
+
() => fetchMergeQueueMembership("o/r", 100, "main", "tok"),
|
|
221
|
+
);
|
|
222
|
+
assertEquals(membership, false);
|
|
223
|
+
});
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// decision drives what the poller does next from the PR's live GitHub state. The regression it
|
|
3
3
|
// guards: a PR that develops a merge CONFLICT after being enqueued (#727/instance 729) must be
|
|
4
4
|
// EVICTED back to the mergeable gate, not left waiting forever — while a PR still legitimately in
|
|
5
|
-
// the queue (reported BLOCKED/UNSTABLE by GitHub) must keep waiting, never be falsely evicted.
|
|
5
|
+
// the queue (reported BLOCKED/UNSTABLE by GitHub) must keep waiting, never be falsely evicted. It
|
|
6
|
+
// also guards #702: a PR EVICTED because required checks failed on the speculative `merge_group`
|
|
7
|
+
// commit is NOT `DIRTY`, so a ground-truth `mergeQueueEntry === false` must classify it `evicted`
|
|
8
|
+
// (the old `DIRTY`-only classifier left it waiting out the full `landedWaitTimeout`, then escalated
|
|
9
|
+
// to a human, instead of auto-re-driving `fix-ci`).
|
|
6
10
|
import { test } from "node:test";
|
|
7
11
|
import { assertEquals } from "#test-assert";
|
|
8
12
|
import type { PrState } from "./github.ts";
|
|
@@ -19,6 +23,7 @@ function st(over: Partial<PrState>): PrState {
|
|
|
19
23
|
totalChecks: 0,
|
|
20
24
|
isDraft: false,
|
|
21
25
|
headRefOid: null,
|
|
26
|
+
mergeQueueEntry: null,
|
|
22
27
|
...over,
|
|
23
28
|
};
|
|
24
29
|
}
|
|
@@ -34,8 +39,39 @@ test("a DIRTY (conflicting) PR is evicted — this is the #727 wedge", () => {
|
|
|
34
39
|
});
|
|
35
40
|
|
|
36
41
|
test("a PR still legitimately in the queue keeps waiting (never falsely evicted)", () => {
|
|
37
|
-
// Queuing PRs commonly report these; none is a conflict,
|
|
42
|
+
// Queuing PRs commonly report these; none is a conflict, and with an unprobed/indeterminate
|
|
43
|
+
// membership (`mergeQueueEntry: null`) none may evict.
|
|
38
44
|
for (const s of ["CLEAN", "BLOCKED", "UNSTABLE", "BEHIND", "HAS_HOOKS", "UNKNOWN", "DRAFT"]) {
|
|
39
45
|
assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s })), "waiting", s);
|
|
40
46
|
}
|
|
41
47
|
});
|
|
48
|
+
|
|
49
|
+
// ── #702: a merge-queue eviction caused by a red `merge_group` build leaves the head NOT `DIRTY`
|
|
50
|
+
// (it reverts to BLOCKED/UNSTABLE/CLEAN). Inferring from `mergeStateStatus` alone kept such a PR
|
|
51
|
+
// "waiting" until the PT1H `landedWaitTimeout` escalated to a human, instead of auto-re-driving
|
|
52
|
+
// `fix-ci`. Ground-truth `mergeQueueEntry === false` now classifies it `evicted`.
|
|
53
|
+
|
|
54
|
+
test("#702: a queued PR dropped from the queue (mergeQueueEntry=false) evicts even when not DIRTY", () => {
|
|
55
|
+
// The exact CI-on-merge_group eviction shapes: no conflict, but no longer enrolled.
|
|
56
|
+
for (const s of ["BLOCKED", "UNSTABLE", "CLEAN", "BEHIND", "HAS_HOOKS", "UNKNOWN"]) {
|
|
57
|
+
assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s, mergeQueueEntry: false })), "evicted", s);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("#702 regression: a PR still ENROLLED (mergeQueueEntry=true) but BLOCKED keeps waiting", () => {
|
|
62
|
+
// A pending queue check reports BLOCKED/UNSTABLE while genuinely still in the queue — must NOT
|
|
63
|
+
// evict, or every legitimately-queuing PR would thrash `arm-merge`.
|
|
64
|
+
for (const s of ["BLOCKED", "UNSTABLE", "CLEAN"]) {
|
|
65
|
+
assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s, mergeQueueEntry: true })), "waiting", s);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("#702: indeterminate membership (mergeQueueEntry=null, e.g. Mergify/token GraphQL error) keeps waiting", () => {
|
|
70
|
+
// A repo with no native merge queue (or an unreadable probe) leaves the #556 `landedWaitTimeout`
|
|
71
|
+
// backstop to handle a never-lands wedge — we must not falsely evict on a perpetually-null entry.
|
|
72
|
+
for (const s of ["BLOCKED", "UNSTABLE", "CLEAN"]) {
|
|
73
|
+
assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s, mergeQueueEntry: null })), "waiting", s);
|
|
74
|
+
}
|
|
75
|
+
// …but a real conflict still evicts regardless of an unprobed membership (token-mode path).
|
|
76
|
+
assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: "DIRTY", mergeQueueEntry: null })), "evicted");
|
|
77
|
+
});
|
package/app/readiness.test.ts
CHANGED
package/app/readiness.ts
CHANGED
|
@@ -704,6 +704,7 @@ export function parsePrView(payload: unknown): PrObservation {
|
|
|
704
704
|
checkConclusions: checkConclusions(rollup),
|
|
705
705
|
isDraft: j.isDraft === true,
|
|
706
706
|
headRefOid: str(j.headRefOid).trim() || null,
|
|
707
|
+
mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
|
|
707
708
|
mergedSha,
|
|
708
709
|
pendingChecks: pending.length,
|
|
709
710
|
};
|
package/app/service.ts
CHANGED
|
@@ -1316,35 +1316,41 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1316
1316
|
//
|
|
1317
1317
|
// A PR enqueued by `attempt-merge` (mergeStatus="queued") parks the process at `wait-landed`.
|
|
1318
1318
|
// Two things can end that wait: the queue lands the PR (→ `merge-landed`), or the PR is EVICTED
|
|
1319
|
-
// from the queue
|
|
1320
|
-
//
|
|
1321
|
-
// `
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
// auto-rebase
|
|
1319
|
+
// from the queue. An eviction has two observable flavours: a merge CONFLICT after its base moved
|
|
1320
|
+
// (`DIRTY` — how #727/instance 729 wedged), OR — the #702 wedge — required checks FAILED on the
|
|
1321
|
+
// speculative `merge_group` commit (the ALLGREEN-batch invalidation), which leaves the
|
|
1322
|
+
// conflict-free head NOT `DIRTY` (it reverts to BLOCKED/UNSTABLE/CLEAN). The latter is invisible to
|
|
1323
|
+
// `mergeStateStatus` alone, so we read GROUND-TRUTH native-queue membership (`withMergeQueue: true`
|
|
1324
|
+
// → GraphQL `mergeQueueEntry`) and let `queuedVerdict` evict on a definitive drop. Without an
|
|
1325
|
+
// eviction path an evicted PR waits out the full `landedWaitTimeout` (default PT1H) then escalates
|
|
1326
|
+
// to a human instead of auto-re-driving `fix-ci`/`rebase`. We must NOT treat a merely "not yet
|
|
1327
|
+
// landed" PR as evicted: while it is legitimately queuing GitHub reports it BLOCKED/UNSTABLE and
|
|
1328
|
+
// `mergeQueueEntry` stays `true`, so `queuedVerdict` keeps waiting. Eviction re-arms the merge
|
|
1329
|
+
// poller (`merge-evicted` → `arm-merge`), re-running the mergeable gate so the existing
|
|
1330
|
+
// auto-rebase / fix-ci / re-enqueue machinery resolves whatever dropped it.
|
|
1327
1331
|
for (const pr of await prs(data).find({ status: "queued" })) {
|
|
1328
1332
|
const { repo, number, pr_key: prKey } = pr;
|
|
1329
1333
|
try {
|
|
1330
|
-
const st = await fetchPrState(repo, number, token);
|
|
1334
|
+
const st = await fetchPrState(repo, number, token, { withMergeQueue: true });
|
|
1331
1335
|
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
1332
1336
|
// Out-of-band terminal FIRST (reusing `st`): a queued PR merged out-of-band lands
|
|
1333
1337
|
// (`merge-landed` → mark-merged); one CLOSED out-of-band without merging can never land, so the
|
|
1334
1338
|
// pre-check re-arms it (`merge-evicted` → arm-merge) and block 2 abandons it — a closed queued
|
|
1335
1339
|
// PR would otherwise wedge, since `queuedVerdict` calls a non-DIRTY closed PR merely "waiting"
|
|
1336
|
-
// (#368). The
|
|
1340
|
+
// (#368). The eviction check below still handles a live queue drop (conflict or CI-on-merge_group).
|
|
1337
1341
|
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1338
1342
|
// Terminal states (merged/closed) are handled by the shared pre-check above; here the PR is
|
|
1339
|
-
// still open, so the only remaining reason to leave `wait-landed` is a live queue DROP — a
|
|
1340
|
-
//
|
|
1343
|
+
// still open, so the only remaining reason to leave `wait-landed` is a live queue DROP — a merge
|
|
1344
|
+
// CONFLICT (`DIRTY`) or a ground-truth `mergeQueueEntry === false`. `queuedVerdict` is the
|
|
1345
|
+
// canonical classifier for both.
|
|
1341
1346
|
if (queuedVerdict(st) === "evicted") {
|
|
1342
1347
|
await flipToMergingThenPublish(data, engine, prKey, "queued", {
|
|
1343
1348
|
name: "merge-evicted",
|
|
1344
1349
|
correlationKey: prKey,
|
|
1345
1350
|
variables: {},
|
|
1346
1351
|
});
|
|
1347
|
-
|
|
1352
|
+
const reason = st.mergeStateStatus === "DIRTY" ? "conflict" : "dropped from queue";
|
|
1353
|
+
console.log(`[poller] queued PR evicted (${reason}) -> ${prKey}`);
|
|
1348
1354
|
}
|
|
1349
1355
|
// otherwise: still legitimately in the queue — keep waiting.
|
|
1350
1356
|
} catch (err) {
|
|
@@ -1356,14 +1362,27 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1356
1362
|
/** Decide what to do with a PR the process enqueued (parked at `wait-landed`), from its current
|
|
1357
1363
|
* GitHub merge state:
|
|
1358
1364
|
* • `landed` — the queue merged it → publish `merge-landed` (advance to mark-merged).
|
|
1359
|
-
* • `evicted` — it fell out of the queue
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1365
|
+
* • `evicted` — it fell out of the queue → publish `merge-evicted` so the process re-arms the
|
|
1366
|
+
* merge poller and the mergeable gate re-runs (auto-rebase for a conflict, `fix-ci` for a red
|
|
1367
|
+
* required check, re-enqueue otherwise). Two independent signals mean "evicted": a live merge
|
|
1368
|
+
* CONFLICT (`DIRTY`) — observable even in token mode — OR ground-truth `mergeQueueEntry === false`,
|
|
1369
|
+
* i.e. the base branch has a native GitHub merge queue but the PR is no longer enrolled in it.
|
|
1370
|
+
* The latter is what catches the #702 wedge: a PR evicted because required checks FAILED on the
|
|
1371
|
+
* speculative `merge_group` commit is NOT `DIRTY` (its head reverts to BLOCKED/UNSTABLE/CLEAN),
|
|
1372
|
+
* so inferring from `mergeStateStatus` alone kept it waiting out the full `landedWaitTimeout`
|
|
1373
|
+
* then pulled in a human, instead of auto-re-driving `fix-ci`.
|
|
1374
|
+
* • `waiting` — still legitimately in the queue. A queuing PR is frequently reported
|
|
1375
|
+
* BLOCKED/UNSTABLE (a pending queue check) — that is NOT eviction. `mergeQueueEntry` is only
|
|
1376
|
+
* `false` when the queue definitively dropped it; `null` (unprobed / token GraphQL error / a
|
|
1377
|
+
* Mergify/plain repo with no native queue) leaves the #556 `landedWaitTimeout` backstop to
|
|
1378
|
+
* handle a genuinely-never-lands wedge, so we never falsely evict there. */
|
|
1364
1379
|
export function queuedVerdict(st: PrState): "landed" | "evicted" | "waiting" {
|
|
1365
1380
|
if (st.merged) return "landed";
|
|
1381
|
+
// A live conflict is an eviction (works in token mode, where `mergeQueueEntry` is unprobed).
|
|
1366
1382
|
if (st.mergeStateStatus === "DIRTY") return "evicted";
|
|
1383
|
+
// Ground-truth: enrolled in a native merge queue and now gone → evicted for ANY reason (a red
|
|
1384
|
+
// `merge_group` build, base moved, manual dequeue), not just a conflict (#702).
|
|
1385
|
+
if (st.mergeQueueEntry === false) return "evicted";
|
|
1367
1386
|
return "waiting";
|
|
1368
1387
|
}
|
|
1369
1388
|
|
package/docs/mcp-runbook.md
CHANGED
|
@@ -24,6 +24,11 @@ MCP is a **third door**, not a replacement: `GET /app/api/agent` (the live guide
|
|
|
24
24
|
`GET /app/api/agent/skill` are unchanged for agents without MCP — see
|
|
25
25
|
[§5 Fallback](#5-fallback).
|
|
26
26
|
|
|
27
|
+
> **Served summary:** a running instance also exposes a nav-linked **"Connect over
|
|
28
|
+
> MCP"** console page (`pages/mcp.page.json`) with copyable config recipes rendered
|
|
29
|
+
> for that instance's own address. It is the short, always-reachable digest; this
|
|
30
|
+
> runbook is the deeper source of truth. Keep the two in sync.
|
|
31
|
+
|
|
27
32
|
## 1. One MCP server entry per instance
|
|
28
33
|
|
|
29
34
|
In `~/.copilot/mcp-config.json` (user-wide) or `.mcp.json` (repo-scoped):
|
|
@@ -122,16 +127,24 @@ door the UI's Cancel uses), never the record-desyncing engine-level `urban_debug
|
|
|
122
127
|
|
|
123
128
|
## 4. Guard posture
|
|
124
129
|
|
|
125
|
-
When `NANO_PR_WEBHOOK_SECRET` is **unset**,
|
|
126
|
-
projections, the operator guide) and mutations
|
|
127
|
-
operations, answering escalations) work
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
When `NANO_PR_WEBHOOK_SECRET` is **unset**, the app guard is off entirely: both reads
|
|
131
|
+
(status, instances, incidents, projections, the operator guide) and mutations
|
|
132
|
+
(cancel/retry/resolve, `start/*` operations, answering escalations) work with no
|
|
133
|
+
credential **from wherever this instance is reachable** — with `network.bind: "all"`
|
|
134
|
+
that is the LAN, not just loopback, so leave it unset only where that exposure is
|
|
135
|
+
acceptable. When it **is set**, the guard is not mutation-only: it also covers
|
|
136
|
+
reads, so read endpoints like `GET /app/api/agent` and `GET /app/api/version`
|
|
137
|
+
return `401` without the `x-hook-secret` header, just as guarded mutations do. It
|
|
138
|
+
is not blanket, though — a few doors stay intentionally unguarded even when the
|
|
139
|
+
secret is set (e.g. the declarative Save-to-library page action, which
|
|
140
|
+
structurally cannot attach the header). Put it in the server entry's
|
|
131
141
|
`headers`, never in chat. For a remote fleet,
|
|
132
|
-
`NANO_WORKFORCE_BASE_URL` reachability rules apply unchanged
|
|
133
|
-
|
|
134
|
-
|
|
142
|
+
`NANO_WORKFORCE_BASE_URL` reachability rules apply unchanged. The rest of the app's
|
|
143
|
+
HTTP surface follows the `network.bind` manifest setting, but the runtime-served
|
|
144
|
+
`/app/mcp` surface is an exception: it is **loopback-only by default** and refuses
|
|
145
|
+
non-loopback peers with a `403` even when `network.bind` is `"all"`, until you
|
|
146
|
+
*also* set `URBAN_MCP_ALLOW_REMOTE=true` (see `e2e/support/mcp-harness.ts`).
|
|
147
|
+
LAN/remote MCP clients therefore need that knob in addition to a wide bind.
|
|
135
148
|
|
|
136
149
|
### Framework mutation guard — `urban_debug_*` mutations need `x-hook-secret` too
|
|
137
150
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.174.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",
|
package/pages/_nav.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"title": "Nano Workforce",
|
|
7
7
|
"items": [
|
|
8
8
|
{ "label": "Overview", "page": "overview" },
|
|
9
|
+
{ "label": "Connect over MCP", "page": "mcp" },
|
|
9
10
|
{ "label": "Lineage", "page": "lineage" },
|
|
10
11
|
{ "label": "Convergence", "page": "home" },
|
|
11
12
|
{ "label": "Epics", "page": "epic" },
|
package/pages/board.page.json
CHANGED
package/pages/cockpit.page.json
CHANGED
package/pages/epic.page.json
CHANGED
package/pages/feature.page.json
CHANGED
package/pages/home.page.json
CHANGED
package/pages/lineage.page.json
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"title": "Connect over MCP",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"type": "nav",
|
|
7
|
+
"id": "nav",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": "bar",
|
|
10
|
+
"title": "Nano Workforce",
|
|
11
|
+
"items": [
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Connect over MCP",
|
|
18
|
+
"page": "mcp"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Lineage",
|
|
22
|
+
"page": "lineage"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Convergence",
|
|
26
|
+
"page": "home"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Epics",
|
|
30
|
+
"page": "epic"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Feature",
|
|
34
|
+
"page": "feature"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"label": "Delivery Graphs",
|
|
38
|
+
"page": "delivery-graphs"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"label": "Tasks",
|
|
42
|
+
"page": "tasks",
|
|
43
|
+
"badge": {
|
|
44
|
+
"source": "app",
|
|
45
|
+
"table": "user_tasks",
|
|
46
|
+
"filter": [],
|
|
47
|
+
"tone": "danger",
|
|
48
|
+
"refreshMs": 5000,
|
|
49
|
+
"hideWhenZero": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"label": "Cockpit",
|
|
54
|
+
"page": "cockpit"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"label": "Board",
|
|
58
|
+
"page": "board"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"label": "Velocity",
|
|
62
|
+
"page": "velocity"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"sticky": true
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"type": "text",
|
|
70
|
+
"id": "title",
|
|
71
|
+
"props": { "text": "Connect over MCP", "variant": "heading" }
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"type": "text",
|
|
75
|
+
"id": "intro",
|
|
76
|
+
"props": {
|
|
77
|
+
"text": "Point a coding agent (Copilot, Claude, Cursor \u2026) at this running instance so the workforce's operations become native tools \u2014 submit work, answer escalations, read status, and debug a wedged instance without curl. The Urban runtime serves a Streamable-HTTP MCP endpoint for this app at the /app/mcp path \u2014 behind a reverse-proxy prefix the reachable URL carries that prefix, as the recipes below render \u2014 and projects its openapi.yaml into tools, with zero MCP code in nwf. Register ONE server entry per instance; its tools are namespaced under the name you give it, so naming the instance targets the right one and makes the wrong-instance mistake very hard to hit (a server pointed at the wrong URL can still misfire). Use the copyable recipes below \u2014 each URL is already rendered for THIS instance's address (including any reverse-proxy prefix).",
|
|
78
|
+
"variant": "sub"
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"type": "button",
|
|
83
|
+
"id": "recipe-config",
|
|
84
|
+
"props": {
|
|
85
|
+
"label": "\ud83d\udccb Config recipe \u2014 local / LAN / remote (mcp-config.json)",
|
|
86
|
+
"variant": "ghost",
|
|
87
|
+
"modal": {
|
|
88
|
+
"title": "One server entry per instance",
|
|
89
|
+
"description": "Add to ~/.copilot/mcp-config.json (user-wide) or .mcp.json (repo-scoped). The workforce-local entry is pre-filled with THIS instance's URL; the merlin/remote entries show the LAN and ngrok shapes \u2014 give each node its own named entry. If THIS instance is guarded (NANO_PR_WEBHOOK_SECRET set), add the same \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" } block to workforce-local too, or its calls 401. MCP servers register at host startup, so add the entry, THEN start a new session for its tools to load.",
|
|
90
|
+
"copyLabel": "Copy config",
|
|
91
|
+
"copyText": "{\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n },\n \"workforce-merlin\": {\n \"type\": \"http\",\n \"url\": \"http://merlin.local:3000/app/mcp\",\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" },\n \"tools\": [\"*\"]\n },\n \"workforce-remote\": {\n \"type\": \"http\",\n \"url\": \"https://<subdomain>.ngrok.app/app/mcp\",\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" },\n \"tools\": [\"*\"]\n }\n }\n}"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"type": "button",
|
|
97
|
+
"id": "recipe-cli",
|
|
98
|
+
"props": {
|
|
99
|
+
"label": "\ud83d\udccb CLI form (copilot mcp add)",
|
|
100
|
+
"variant": "ghost",
|
|
101
|
+
"modal": {
|
|
102
|
+
"title": "Add from the terminal",
|
|
103
|
+
"description": "For a guarded instance (NANO_PR_WEBHOOK_SECRET set), pass the shared-secret header with the --header form (the second command); an unguarded instance needs only the first.",
|
|
104
|
+
"copyLabel": "Copy commands",
|
|
105
|
+
"copyText": "# This instance if unguarded (no NANO_PR_WEBHOOK_SECRET set):\ncopilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\n# This instance if guarded (NANO_PR_WEBHOOK_SECRET set) \u2014 present the shared secret header:\ncopilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\""
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"type": "text",
|
|
111
|
+
"id": "secret-heading",
|
|
112
|
+
"props": { "text": "Shared-secret setup (x-hook-secret)", "variant": "heading" }
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"type": "text",
|
|
116
|
+
"id": "secret-body",
|
|
117
|
+
"props": {
|
|
118
|
+
"text": "When this instance sets NANO_PR_WEBHOOK_SECRET, the guard is NOT mutation-only \u2014 it also covers reads, so read endpoints like GET /app/api/agent and GET /app/api/version return 401 without the x-hook-secret header, just as guarded mutations do. It is not blanket, though: a few doors stay intentionally unguarded even when the secret is set (e.g. the declarative Save-to-library page action, which structurally cannot attach the header). The mcp-config.json path takes the shared secret as a headers block, not a flag, so add a headers block alongside url on the server entry (copy it below); omitting it yields 401s. Put the secret in the server entry's headers, never in chat. When NANO_PR_WEBHOOK_SECRET is unset, the app guard is off entirely \u2014 reads and mutations both work with no credential from wherever this instance is reachable (with network.bind \"all\" that is the LAN, not just loopback), so unset it only where that exposure is acceptable. Note the runtime-served /app/mcp surface is an exception to that reachability: it is loopback-only by default and refuses non-loopback peers with a 403 even when network.bind is \"all\", until you also set URBAN_MCP_ALLOW_REMOTE=true \u2014 LAN/remote MCP clients need that knob in addition to a wide bind.",
|
|
119
|
+
"variant": "sub"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"type": "button",
|
|
124
|
+
"id": "recipe-secret",
|
|
125
|
+
"props": {
|
|
126
|
+
"label": "\ud83d\udccb Shared-secret header block",
|
|
127
|
+
"variant": "ghost",
|
|
128
|
+
"modal": {
|
|
129
|
+
"title": "Add the x-hook-secret header",
|
|
130
|
+
"description": "Drop this headers entry alongside url on the guarded server's config-form entry.",
|
|
131
|
+
"copyLabel": "Copy headers",
|
|
132
|
+
"copyText": "\"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"type": "text",
|
|
138
|
+
"id": "basic-auth-heading",
|
|
139
|
+
"props": { "text": "Basic-Auth-fronted instances (reverse proxy)", "variant": "heading" }
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"type": "text",
|
|
143
|
+
"id": "basic-auth-body",
|
|
144
|
+
"props": {
|
|
145
|
+
"text": "These are two different layers. x-hook-secret is the app's own guard, checked by nwf. Basic Auth is enforced by whatever fronts the instance (ngrok edge, console proxy) and 401s before the request ever reaches nwf. A Basic-Auth-fronted instance therefore needs BOTH headers on the connection: Authorization: Basic \u2026 for the proxy AND x-hook-secret for the app. Generate the blob with printf '%s' 'user:pass' | base64 (echo appends a newline and yields the wrong value); Base64 is encoding, not encryption \u2014 only use Basic Auth over HTTPS.",
|
|
146
|
+
"variant": "sub"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"type": "button",
|
|
151
|
+
"id": "recipe-basic-auth",
|
|
152
|
+
"props": {
|
|
153
|
+
"label": "\ud83d\udccb Basic-Auth + secret (both headers)",
|
|
154
|
+
"variant": "ghost",
|
|
155
|
+
"modal": {
|
|
156
|
+
"title": "Both headers, two different layers",
|
|
157
|
+
"description": "Proxy layer (Authorization) plus app layer (x-hook-secret) on the same server entry.",
|
|
158
|
+
"copyLabel": "Copy headers",
|
|
159
|
+
"copyText": "\"headers\": {\n \"Authorization\": \"Basic <base64(user:pass)>\",\n \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\"\n}"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"type": "text",
|
|
165
|
+
"id": "mutation-guard-heading",
|
|
166
|
+
"props": { "text": "urban_debug_* mutation-guard caveat", "variant": "heading" }
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"type": "text",
|
|
170
|
+
"id": "mutation-guard-body",
|
|
171
|
+
"props": {
|
|
172
|
+
"text": "The framework's mutating engine-debug tools (set_variables / retry_job / resolve_incident / cancel_instance) require the app's shared-secret scheme (or the loopback-only allowMutations opt-in when allowRemote is off). Until issue #698 declares x-nano-secret-env, remote mutations are refused on any allowRemote-on instance even with reads open \u2014 that is the current posture, tracked in #698. The read tools (urban_debug_search_process_instances / _element_instance_wait_states / _incidents, and where projected _jobs / _variables) work under the same x-hook-secret as the rest of the surface. Operator-only doors stay operator-only: the delivery-graph stage / dispatch / dismiss lifecycle is x-mcp-excluded from the tool surface \u2014 the human clicking Dispatch in the cockpit IS the approval \u2014 so an agent cannot dispatch a delivery graph through MCP (it authors graphs through the pure compileDeliveryGraph / previewDeliveryGraph doors, which stay exposed).",
|
|
173
|
+
"variant": "sub"
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"type": "text",
|
|
178
|
+
"id": "fallback-heading",
|
|
179
|
+
"props": { "text": "Fallback \u2014 no MCP client", "variant": "heading" }
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"type": "text",
|
|
183
|
+
"id": "fallback-body",
|
|
184
|
+
"props": {
|
|
185
|
+
"text": "Agents without an MCP client are unchanged \u2014 fetch and follow this instance's live operator guide over curl (the response is JSON with a skill markdown field). Add -H \"x-hook-secret: <secret>\" (and -u user:pass for a Basic-Auth-fronted instance) if this instance is guarded. That skill bootstraps you to the same live guide MCP exposes as the getAgentInstructions tool \u2014 or, over MCP, its addressable companion getAgentGuide(section?), which the runbook recommends over the ~43KB blob to avoid a tool-result overrun.",
|
|
186
|
+
"variant": "sub"
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"type": "button",
|
|
191
|
+
"id": "recipe-fallback",
|
|
192
|
+
"props": {
|
|
193
|
+
"label": "\ud83d\udccb Fallback curl (no MCP client)",
|
|
194
|
+
"variant": "ghost",
|
|
195
|
+
"modal": {
|
|
196
|
+
"title": "The curl door is unchanged",
|
|
197
|
+
"description": "Fetch this instance's live guide directly; add the header(s) if guarded.",
|
|
198
|
+
"copyLabel": "Copy curl",
|
|
199
|
+
"copyText": "curl -sS {{appBase}}app/api/agent/skill\n# guarded instance \u2014 add the shared secret (and Basic Auth if fronted):\ncurl -sS {{appBase}}app/api/agent/skill \\\n -H \"x-hook-secret: <secret>\""
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"type": "text",
|
|
205
|
+
"id": "runbook-heading",
|
|
206
|
+
"props": { "text": "Deeper reference", "variant": "heading" }
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"type": "text",
|
|
210
|
+
"id": "runbook-body",
|
|
211
|
+
"props": {
|
|
212
|
+
"text": "This served page is the short, always-reachable summary. The full runbook \u2014 discovery, debugging a wedged instance, guard posture, the projected-tool-schema notes and the regression harness \u2014 lives in the repo at docs/mcp-runbook.md (https://github.com/nanobpm/nano-workforce/blob/main/docs/mcp-runbook.md), with README.md \u00a7\"Configure an agent over MCP\" as its companion. Keep the two in sync: the runbook is the source of truth, this page is the served digest.",
|
|
213
|
+
"variant": "sub"
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
}
|
package/pages/overview.page.json
CHANGED
package/pages/tasks.page.json
CHANGED
|
@@ -9,12 +9,34 @@
|
|
|
9
9
|
"variant": "bar",
|
|
10
10
|
"title": "Nano Workforce",
|
|
11
11
|
"items": [
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Connect over MCP",
|
|
18
|
+
"page": "mcp"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Lineage",
|
|
22
|
+
"page": "lineage"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Convergence",
|
|
26
|
+
"page": "home"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Epics",
|
|
30
|
+
"page": "epic"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Feature",
|
|
34
|
+
"page": "feature"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"label": "Delivery Graphs",
|
|
38
|
+
"page": "delivery-graphs"
|
|
39
|
+
},
|
|
18
40
|
{
|
|
19
41
|
"label": "Tasks",
|
|
20
42
|
"page": "tasks",
|
|
@@ -27,9 +49,18 @@
|
|
|
27
49
|
"hideWhenZero": true
|
|
28
50
|
}
|
|
29
51
|
},
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
{
|
|
53
|
+
"label": "Cockpit",
|
|
54
|
+
"page": "cockpit"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"label": "Board",
|
|
58
|
+
"page": "board"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"label": "Velocity",
|
|
62
|
+
"page": "velocity"
|
|
63
|
+
}
|
|
33
64
|
],
|
|
34
65
|
"sticky": true
|
|
35
66
|
}
|