@nanobpm/nano-workforce 0.91.0 → 0.93.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.
@@ -0,0 +1,147 @@
1
+ // Scope-integrity guard — unit tests for the canonical router (app/scopeGuard.ts) and its parsing
2
+ // helpers.
3
+ //
4
+ // A parity slice can be silently under-delivered: an agent splits a large slice, ships one half,
5
+ // then `Closes #N` a broader-scoped parent while recording the deferred remainder only in PR prose
6
+ // (a `## Scope` section) with no filed follow-up issue. Magikcraft/nano-bpm#631 → PR #863 did
7
+ // exactly this and the deferred half was lost until a human re-filed it as #872. These two guards
8
+ // (#313) block that class: a partial delivery may not close-keyword a broader-scoped parent, and any
9
+ // deferral must link a filed follow-up issue rather than live in prose.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
12
+ import {
13
+ evaluateScopeGuard,
14
+ findClosingKeywordRefs,
15
+ hasDeferralMarker,
16
+ hasFollowupIssueRef,
17
+ } from "./scopeGuard.ts";
18
+
19
+ // ── The canonical router ────────────────────────────────────────────────────
20
+
21
+ test("evaluateScopeGuard: a full-scope PR that Closes its parent, no deferral, is allowed", () => {
22
+ const r = evaluateScopeGuard({ prBody: "Implements the feature end to end.\n\nCloses #313" });
23
+ assertEquals(r.scopeBlocked, false);
24
+ assertEquals(r.scopeBlockReason, "");
25
+ });
26
+
27
+ test("evaluateScopeGuard: a plain PR with no closing keyword and no deferral is allowed", () => {
28
+ const r = evaluateScopeGuard({ prBody: "A small refactor. Refs #10" });
29
+ assertEquals(r.scopeBlocked, false);
30
+ });
31
+
32
+ test("evaluateScopeGuard: Closes a broader parent AND defers scope → blocked (guard 1)", () => {
33
+ const r = evaluateScopeGuard({
34
+ prBody:
35
+ "Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
36
+ });
37
+ assertEquals(r.scopeBlocked, true);
38
+ assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
39
+ assertStringIncludes(r.scopeBlockReason, "#631");
40
+ });
41
+
42
+ test("evaluateScopeGuard: defers scope but links NO follow-up issue → blocked (guard 2)", () => {
43
+ const r = evaluateScopeGuard({
44
+ prBody: "Ships the first half.\n\n## Scope\nThe rest is deferred.\n\nRefs #631",
45
+ });
46
+ assertEquals(r.scopeBlocked, true);
47
+ assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
48
+ // Guard 1 must NOT fire — this PR correctly used a non-closing ref.
49
+ assert(
50
+ !r.scopeBlockReason.includes("must not close"),
51
+ "a non-closing ref must not trip the closing-keyword guard",
52
+ );
53
+ });
54
+
55
+ test("evaluateScopeGuard: defers scope AND links a filed follow-up AND uses a non-closing ref → allowed", () => {
56
+ const r = evaluateScopeGuard({
57
+ prBody:
58
+ "Ships the first half.\n\n## Scope\nThe embedded SUB_PROCESS half is deferred.\nTracked-in: #872\n\nRefs #631",
59
+ });
60
+ assertEquals(r.scopeBlocked, false);
61
+ assertEquals(r.scopeBlockReason, "");
62
+ });
63
+
64
+ test("evaluateScopeGuard: the motivating incident (Closes #631 + ## Scope + no follow-up) trips BOTH guards", () => {
65
+ const r = evaluateScopeGuard({
66
+ prBody:
67
+ "## Summary\nNested ad-hoc / agent-of-agents delivered.\n\n## Scope\nembedded `SUB_PROCESS` tools whose multi-element body runs by token flow remain the deferred refinement.\n\nCloses #631",
68
+ });
69
+ assertEquals(r.scopeBlocked, true);
70
+ assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
71
+ assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
72
+ });
73
+
74
+ test("evaluateScopeGuard: a follow-up link alone does not excuse a closing keyword on a split", () => {
75
+ // Even with the remainder tracked, closing the broader parent is still wrong — it reads as done.
76
+ const r = evaluateScopeGuard({
77
+ prBody: "Ships half.\n\nDeferred: the rest. Follow-up: #872\n\nCloses #631",
78
+ });
79
+ assertEquals(r.scopeBlocked, true);
80
+ assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
81
+ assert(!r.scopeBlockReason.includes("no filed follow-up issue"), "the follow-up was linked");
82
+ });
83
+
84
+ test("evaluateScopeGuard: tolerates null / empty bodies", () => {
85
+ assertEquals(evaluateScopeGuard({ prBody: null }).scopeBlocked, false);
86
+ assertEquals(evaluateScopeGuard({ prBody: undefined }).scopeBlocked, false);
87
+ assertEquals(evaluateScopeGuard({ prBody: "" }).scopeBlocked, false);
88
+ });
89
+
90
+ // ── The parsers ─────────────────────────────────────────────────────────────
91
+
92
+ test("findClosingKeywordRefs: extracts bare, cross-repo, and URL closing refs; dedupes", () => {
93
+ const body = [
94
+ "Closes #12",
95
+ "fixes: owner/repo#34",
96
+ "Resolved https://github.com/owner/repo/issues/56",
97
+ "Closes #12", // duplicate
98
+ ].join("\n");
99
+ assertEquals(findClosingKeywordRefs(body), [
100
+ "#12",
101
+ "owner/repo#34",
102
+ "https://github.com/owner/repo/issues/56",
103
+ ]);
104
+ });
105
+
106
+ test("findClosingKeywordRefs: a non-closing ref (Refs / Part of) is not a closing keyword", () => {
107
+ assertEquals(findClosingKeywordRefs("Refs #12\nPart of #34\nDepends-on: #56"), []);
108
+ });
109
+
110
+ test("hasDeferralMarker: detects a ## Scope heading and deferral phrases; ignores clean prose", () => {
111
+ assert(hasDeferralMarker("## Scope\nfoo"), "a Scope heading defers");
112
+ assert(hasDeferralMarker("### scope of work"), "any heading level counts");
113
+ assert(hasDeferralMarker("The rest is deferred to later."), "'deferred' defers");
114
+ assert(hasDeferralMarker("This is out of scope for now."), "'out of scope' defers");
115
+ assert(hasDeferralMarker("The remainder is left for a follow-up."), "'remainder' defers");
116
+ assert(!hasDeferralMarker("Implements everything. Closes #1."), "clean prose does not defer");
117
+ });
118
+
119
+ test("hasDeferralMarker: a bare 'remain*' without deferral context is not a deferral", () => {
120
+ // "all done" phrasing must not be read as a scope deferral (Copilot advisory,
121
+ // app/scopeGuard.ts:48): a full-scope PR that merely reports nothing outstanding
122
+ // would otherwise be blocked from converging.
123
+ assert(!hasDeferralMarker("No issues remain.\n\nCloses #123"), "'No issues remain' is not a deferral");
124
+ assert(!hasDeferralMarker("All checks remain green."), "'remain green' is not a deferral");
125
+ assert(!hasDeferralMarker("No failing tests remaining. Closes #7"), "'remaining' alone is not a deferral");
126
+ // ...but a remainder mention near genuine deferral context still defers.
127
+ assert(hasDeferralMarker("The remaining scope is tracked separately."), "'remaining' near 'scope' defers");
128
+ assert(hasDeferralMarker("Remaining work is a follow-up."), "'remaining' near 'follow-up' defers");
129
+ });
130
+
131
+ test("hasFollowupIssueRef: only an explicit tracking marker + issue ref counts", () => {
132
+ assert(hasFollowupIssueRef("Deferred-to: #872"), "Deferred-to marker");
133
+ assert(hasFollowupIssueRef("Tracked-in: owner/repo#872"), "cross-repo tracking marker");
134
+ assert(hasFollowupIssueRef("Follow-up: #900"), "Follow-up marker");
135
+ assert(hasFollowupIssueRef("Follow up issue: #900"), "Follow up issue marker");
136
+ assert(!hasFollowupIssueRef("The rest is deferred."), "bare deferral prose is not a filed link");
137
+ assert(!hasFollowupIssueRef("Refs #631"), "a parent ref is not a remainder tracker");
138
+ // A full GitHub issue URL is a valid filed follow-up link, same as the closing-keyword parser accepts.
139
+ assert(
140
+ hasFollowupIssueRef("Deferred-to: https://github.com/owner/repo/issues/872"),
141
+ "Deferred-to marker with a full issue URL",
142
+ );
143
+ assert(
144
+ hasFollowupIssueRef("Follow-up issue: https://github.com/nanobpm/nano-workforce/issues/900"),
145
+ "Follow-up marker with a full issue URL",
146
+ );
147
+ });
@@ -0,0 +1,131 @@
1
+ // Scope-integrity gate for the review-convergence loop (issue #313).
2
+ //
3
+ // A parity slice can be silently under-delivered: an agent legitimately splits a large slice, ships
4
+ // one half, but then (a) uses a `Closes #N` closing keyword on an issue whose stated scope was
5
+ // broader than what shipped, and (b) records the deferred remainder only in PR/commit prose (a
6
+ // `## Scope` section) rather than as a filed, tracked issue. The parent then reads as fully done —
7
+ // `gh issue list` shows nothing outstanding — and downstream consumers trust "issue closed =
8
+ // capability present". This is exactly how Magikcraft/nano-bpm#631 → PR #863 (`## Scope` deferral,
9
+ // `Closes #631`, no follow-up) lost the deferred half until a human re-filed it by hand as #872.
10
+ //
11
+ // This pure router encodes the two guards proposed in #313, evaluated over the PR description body
12
+ // so the deterministic converge-gate (`workers/converge-gate`) can block a partial delivery from
13
+ // closing a broader-scoped parent, escalating to the human `wait-answer` task instead of merging:
14
+ //
15
+ // 1. Closing-keyword integrity — a PR may only carry `Closes/Fixes/Resolves #N` when it delivers
16
+ // #N's full stated scope. When the same body ALSO defers scope (a `## Scope` section /
17
+ // "deferred" / "out of scope" / "remains"), the closing keyword is flagged: the PR must instead
18
+ // use a non-closing ref (`Refs #N` / `Part of #N`) and leave #N open (or convert #N into a
19
+ // tracking issue).
20
+ // 2. Deferred ⇒ filed issue, not prose — any PR that defers part of its scope must LINK a filed
21
+ // follow-up issue for the remainder (`Deferred-to: #N` / `Tracked-in: #N` / `Follow-up: #N`). A
22
+ // deferral that exists only in commit/ADR/PR text is a drift surface (invisible, unclaimable
23
+ // work); mirror the repo's "no drift surfaces" rule, applied to scope.
24
+ //
25
+ // Both guards fire only when the body actually DEFERS scope, so a full-scope `Closes #N` PR with no
26
+ // deferral prose passes untouched.
27
+
28
+ export interface ScopeGuardInput {
29
+ /** The PR description body (the text `gh pr create --body` set). */
30
+ prBody: string | null | undefined;
31
+ }
32
+
33
+ export interface ScopeGuardResult {
34
+ scopeBlocked: boolean;
35
+ scopeBlockReason: string;
36
+ }
37
+
38
+ // GitHub's closing keywords (close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved)
39
+ // followed by an issue ref: a bare `#123`, a cross-repo `owner/repo#123`, or a full issue URL. The
40
+ // keyword and ref may be separated by whitespace and/or a colon (`Closes: #1`, `Closes #1`).
41
+ const CLOSING_KEYWORD =
42
+ /\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)[\s:]+(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|(?:[\w.-]+\/[\w.-]+)?#\d+)/gi;
43
+
44
+ // A body DEFERS scope when it carries a `## Scope` (any heading level) section OR names a deferral in
45
+ // prose. A bare `remain*`/`remainder` is deliberately NOT enough on its own — normal "all done"
46
+ // phrasing ("No issues remain.", "no failing tests remaining") uses it without deferring any scope,
47
+ // and flagging that would block a full-scope PR from converging. A remainder mention only defers when
48
+ // a deferral-context term (scope / follow-up / later / to-do / tracking) sits near it; the incident's
49
+ // honest deferral read "…remain the deferred refinement", which the explicit `defer*` branch catches.
50
+ const DEFERRAL_HEADING = /^#{1,6}\s+scope\b/im;
51
+ const DEFERRAL_PHRASE = /\bdefer(?:s|red|ral|ring)?\b|\bout[- ]of[- ]scope\b/i;
52
+ const REMAINDER_WORD = /\bremain(?:s|der|ing)?\b/gi;
53
+ const REMAINDER_CONTEXT = /\b(?:scope|follow[- ]?ups?|later|to[- ]?dos?|track(?:s|ed|ing)?|next[- ]steps?)\b/i;
54
+ const REMAINDER_WINDOW = 48;
55
+
56
+ // Whether any `remain*` mention sits within a short window of a deferral-context term.
57
+ function remainderDefersScope(text: string): boolean {
58
+ for (const m of text.matchAll(REMAINDER_WORD)) {
59
+ const idx = m.index ?? 0;
60
+ const window = text.slice(Math.max(0, idx - REMAINDER_WINDOW), idx + m[0].length + REMAINDER_WINDOW);
61
+ if (REMAINDER_CONTEXT.test(window)) {
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // A FILED follow-up issue link for the deferred remainder: an explicit tracking marker followed by
69
+ // an issue ref — a bare `#123`, a cross-repo `owner/repo#123`, or a full issue URL (matching the
70
+ // closing-keyword parser, so an explicit `https://github.com/<owner>/<repo>/issues/<n>` link counts
71
+ // as a tracked follow-up rather than being mistaken for untracked prose). This is the
72
+ // machine-checkable contract feature.md asks split slices to emit.
73
+ const FOLLOWUP_MARKER =
74
+ /\b(?:deferred[- ]to|tracked[- ]in|tracking issue|follow[- ]?ups?(?:\s+issue)?)\b[\s:]*(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|(?:[\w.-]+\/[\w.-]+)?#\d+)/i;
75
+
76
+ /** The distinct issue refs a body closes via a GitHub closing keyword, in first-seen order. */
77
+ export function findClosingKeywordRefs(body: string | null | undefined): string[] {
78
+ const text = body ?? "";
79
+ const refs: string[] = [];
80
+ const seen = new Set<string>();
81
+ for (const m of text.matchAll(CLOSING_KEYWORD)) {
82
+ const ref = m[0].slice(m[1].length).replace(/^[\s:]+/, "").trim();
83
+ if (!seen.has(ref)) {
84
+ seen.add(ref);
85
+ refs.push(ref);
86
+ }
87
+ }
88
+ return refs;
89
+ }
90
+
91
+ /** Whether the body defers part of its scope (a `## Scope` section or a deferral phrase). */
92
+ export function hasDeferralMarker(body: string | null | undefined): boolean {
93
+ const text = body ?? "";
94
+ return DEFERRAL_HEADING.test(text) || DEFERRAL_PHRASE.test(text) || remainderDefersScope(text);
95
+ }
96
+
97
+ /** Whether the body links a filed follow-up issue for the deferred remainder. */
98
+ export function hasFollowupIssueRef(body: string | null | undefined): boolean {
99
+ return FOLLOWUP_MARKER.test(body ?? "");
100
+ }
101
+
102
+ /** Decide whether a PR's scope framing is safe to converge/merge. Pure; the worker feeds it the
103
+ * live PR body and fails CLOSED (blocks) when that body cannot be read. */
104
+ export function evaluateScopeGuard(input: ScopeGuardInput): ScopeGuardResult {
105
+ const body = input.prBody ?? "";
106
+ const defers = hasDeferralMarker(body);
107
+ const reasons: string[] = [];
108
+
109
+ if (defers) {
110
+ const closing = findClosingKeywordRefs(body);
111
+ if (closing.length > 0) {
112
+ const noun = closing.length === 1 ? "issue" : "issues";
113
+ reasons.push(
114
+ `this PR defers part of its scope yet closing-keywords ${noun} ${closing.join(", ")} — a partial delivery must not close a broader-scoped issue; use a non-closing ref (Refs #N / Part of #N) and leave it open (or convert it into a tracking issue)`,
115
+ );
116
+ }
117
+ if (!hasFollowupIssueRef(body)) {
118
+ reasons.push(
119
+ "this PR defers part of its scope but links no filed follow-up issue for the remainder — file a tracking issue for each deferred item and link it (Deferred-to: #N / Tracked-in: #N / Follow-up: #N) so the remainder is tracked, not left in PR prose",
120
+ );
121
+ }
122
+ }
123
+
124
+ if (reasons.length === 0) {
125
+ return { scopeBlocked: false, scopeBlockReason: "" };
126
+ }
127
+ return {
128
+ scopeBlocked: true,
129
+ scopeBlockReason: `Scope integrity blocked: ${reasons.join("; ")}.`,
130
+ };
131
+ }
package/app/service.ts CHANGED
@@ -33,7 +33,7 @@ import { pollLineage } from "./lineage.ts";
33
33
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
34
34
  import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
35
35
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
36
- import { planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
36
+ import { backfillPlanBuckets, planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
37
37
  import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
38
38
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
39
39
  import { trialMergeAudits } from "./trialMerge.ts";
@@ -1773,6 +1773,11 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1773
1773
  * on every poll. */
1774
1774
  let featureStagesBackfilled = false;
1775
1775
 
1776
+ /** One-shot guard so the epic-bucket backfill (`backfillPlanBuckets`, #298) runs at most once per
1777
+ * process, on the first `pollOnce` — re-projecting pre-migration-042 `plans` rows whose
1778
+ * `list_bucket` is still NULL. The gateway keeps every future write fresh; idempotent regardless. */
1779
+ let planBucketsBackfilled = false;
1780
+
1776
1781
  export async function pollOnce(
1777
1782
  data: DataLayer,
1778
1783
  engine: EngineClient,
@@ -1789,6 +1794,13 @@ export async function pollOnce(
1789
1794
  await backfillFeatureStages(data);
1790
1795
  featureStagesBackfilled = true;
1791
1796
  }
1797
+ // One-shot: re-project any pre-#298 `plans` rows whose `list_bucket` is still NULL, so a legacy
1798
+ // epic buckets correctly into Active/History from the first pass. Guard armed only after success so
1799
+ // a transient failure retries next pass (mirrors the feature-stage backfill above).
1800
+ if (!planBucketsBackfilled) {
1801
+ await backfillPlanBuckets(data);
1802
+ planBucketsBackfilled = true;
1803
+ }
1792
1804
  await pollReviews(data, engine, token);
1793
1805
  await pollMerges(data, engine, token);
1794
1806
  await pollDelivery(data);
@@ -0,0 +1,32 @@
1
+ -- 044_plan_list_bucket.sql — issue #298: bucket EPICS on the derived `delivery` rollup, not raw
2
+ -- `plan.status`, so a `done` epic whose slice PRs are still CONVERGING — or one that has fully LANDED
3
+ -- but still needs its integration→main promotion PR — does NOT silently vanish from the Active epic
4
+ -- lists the instant `status = done`. Mirrors the feature-run Active/History tick-off partition
5
+ -- (038/039 `feature_runs`): reify the partition as a derived, write-time-projected column so the
6
+ -- declarative epic/overview page tabs can filter with only stored `{"field":…}` `in` clauses (the
7
+ -- dataGrid page DSL has no OR / IS NULL), and give epics the same operator "Dismiss" affordance
8
+ -- feature runs already have.
9
+ --
10
+ -- Columns (all maintained by the `plans` gateway — app/plan.ts — from the pure `deriveEpicBucket` /
11
+ -- `epicIsAcknowledgeable` helpers in app/delivery.ts on every write, never hand-derived in SQL, the
12
+ -- page, or a poller):
13
+ -- • acknowledged_at — NULL until an operator dismisses a RESOLVED epic (acknowledge-epic) — landed
14
+ -- or resolved-not-landed (`delivery=null`); only still-`converging` epics are
15
+ -- rejected. The twin of feature_runs.acknowledged_at (039).
16
+ -- • list_bucket — 'active' | 'history': deriveEpicBucket(status, delivery, acknowledged_at).
17
+ -- Active = live epics (planning/dispatched) + `done` epics not yet acknowledged
18
+ -- (still converging, landed-but-unpromoted, or resolved-not-landed); History =
19
+ -- acknowledged `done` epics and terminal failed/abandoned epics.
20
+ -- • ack_open — 1 | 0: 1 iff the epic is a RESOLVED (`done`, not `converging`) but
21
+ -- unacknowledged epic — the Active states that carry the Dismiss affordance — so
22
+ -- the page's `showWhenField` gates the button precisely. Mirrors
23
+ -- feature_runs.escalation_open (040).
24
+ --
25
+ -- Forward-only, additive (expand): all nullable with no default, so pre-#298 rows grandfather in as
26
+ -- NULL and never gate control flow. `backfillPlanBuckets` (app/plan.ts) stamps legacy rows once at
27
+ -- boot, and the gateway keeps every future write fresh. Numbered after the current highest prefix on
28
+ -- origin/main (041); the runner wraps each file in its own transaction, so this file must NOT contain
29
+ -- BEGIN/COMMIT.
30
+ ALTER TABLE plans ADD COLUMN acknowledged_at TEXT;
31
+ ALTER TABLE plans ADD COLUMN list_bucket TEXT;
32
+ ALTER TABLE plans ADD COLUMN ack_open INTEGER;
@@ -0,0 +1,56 @@
1
+ -- 045_epic_set_admission_staging.sql — issue #292 slice S2: durable ADMISSION STAGING for the
2
+ -- set/batch door (`startEpicSet`).
3
+ --
4
+ -- S2 is the admission DOOR + DAG validator only; it deliberately does NOT start any epic and does
5
+ -- NOT materialize the durable plan graph. Slice S3 (planner lowering: schedule roots, seed the
6
+ -- capability gate, bind the resolved version) is the slice that actually CREATES `plans` rows and
7
+ -- their `plan_deps` edges — so S3, not S2, is the correct owner of both.
8
+ --
9
+ -- That split leaves S2 needing to persist WHAT it admitted so a crash between admission and lowering
10
+ -- does not lose the set. It cannot write `plan_deps` for that: `plan_deps.plan_key REFERENCES
11
+ -- plans(plan_key)` (041), but S2 has not created any `plans` row, so a first-time set submission
12
+ -- would FK-fail (500). Nor should it pre-create a `plans` row — a non-terminal `plans` row reads as
13
+ -- `alreadyRunning` to the canonical `startPlan`, which would wedge S3 from ever starting it.
14
+ --
15
+ -- So S2 stages into its OWN, FK-FREE structure here, and S3 reads it during lowering to materialize
16
+ -- `plans` + `plan_deps` when it schedules roots (where the FK is satisfied by construction). Neither
17
+ -- staging table references `plans` — that is the whole point: the staging is writable BEFORE any
18
+ -- plan graph exists.
19
+ --
20
+ -- • admitted_epics — one row per admitted epic in the set (INCLUDING roots, which carry no edge).
21
+ -- `plan_key` is the epic's canonical key; the rest is what S3 needs to materialize the `plans`
22
+ -- row (repo, issue number/url, the normalized integration base branch). PRIMARY KEY (plan_key)
23
+ -- makes a re-submitted set idempotent (one staged row per epic).
24
+ -- • admitted_plan_deps — the FK-FREE staging twin of `plan_deps`: one row per validated inter-epic
25
+ -- edge (`plan_key` waits for `depends_on_plan_key`, gated by { package, capability_ref }).
26
+ -- Constraints MIRROR plan_deps EXCEPT the FK: PRIMARY KEY (plan_key, depends_on_plan_key) so a
27
+ -- re-submitted set cannot duplicate an edge, CHECK (plan_key <> depends_on_plan_key) so no
28
+ -- self-edge — but NO `REFERENCES plans(...)`, since neither endpoint's plan row exists yet. The
29
+ -- set validator (S2) is what guarantees every endpoint names a submitted epic.
30
+ --
31
+ -- Numbered after the current highest prefix on origin/main (042_plan_promotion.sql) — the branch
32
+ -- forks at 041 while main advanced to 042, so this MUST be 043 to avoid a merge-time prefix
33
+ -- collision. The runner wraps each file in its own transaction, so this file must NOT contain
34
+ -- BEGIN/COMMIT.
35
+
36
+ CREATE TABLE admitted_epics (
37
+ plan_key TEXT NOT NULL PRIMARY KEY, -- the admitted epic's canonical key (owner/repo#123)
38
+ repo TEXT NOT NULL, -- owner/repo the epic issue lives in
39
+ issue_number INTEGER NOT NULL, -- the epic issue number
40
+ issue_url TEXT NOT NULL, -- canonical issue URL (for S3 to materialize the plans row)
41
+ base_branch TEXT NOT NULL, -- normalized integration base branch admitPlan resolved
42
+ created_at TEXT NOT NULL
43
+ );
44
+
45
+ CREATE TABLE admitted_plan_deps (
46
+ plan_key TEXT NOT NULL, -- dependent/consumer epic that waits (FK-free: plans may not exist yet)
47
+ depends_on_plan_key TEXT NOT NULL, -- producer epic it waits for
48
+ package TEXT NOT NULL, -- producer's published package name
49
+ capability_ref TEXT NOT NULL, -- producer epic issue handle → pkg@version
50
+ created_at TEXT NOT NULL,
51
+ PRIMARY KEY (plan_key, depends_on_plan_key),
52
+ CHECK (plan_key <> depends_on_plan_key)
53
+ );
54
+
55
+ CREATE INDEX idx_admitted_plan_deps_plan ON admitted_plan_deps(plan_key);
56
+ CREATE INDEX idx_admitted_plan_deps_producer ON admitted_plan_deps(depends_on_plan_key);
@@ -1,17 +1,33 @@
1
1
  import type { EngineClient } from "@nanobpm/urban";
2
2
  import type { TestApp } from "@nanobpm/urban-testkit";
3
3
 
4
- // urban 0.49.0 (ADR 0062) added `EngineClient.getForm`, but the published
5
- // @nanobpm/urban-testkit (0.4.0) predates it, so its `WasmEngineClient` neither
6
- // declares nor implements the method. These hermetic e2e flows drive user-task
7
- // completion directly (`completeUserTask`) and never resolve a form schema, so we
8
- // complete the contract with a null-returning `getForm` the documented "no
9
- // matching form" path until a testkit release catches up with urban's engine
10
- // seam. Scoped to the test harness; production adapters implement `getForm` for real.
4
+ // `@nanobpm/urban-testkit`'s `WasmEngineClient` has historically lagged urban's `EngineClient`
5
+ // interface: a method lands on the real seam a release or two before the testkit fake grows it. Both
6
+ // guards below defensively COMPLETE the contract for any method the *installed* testkit hasn't
7
+ // implemented yet each is an idempotent `if (typeof … !== "function")`, so it no-ops the moment a
8
+ // testkit release ships the real method (no version pins to drift). Scoped to the test harness;
9
+ // production adapters (`SdkEngineClient`) implement both for real.
10
+ //
11
+ // - `getForm` (urban 0.49.0, ADR 0062): the original instance of this lag. Now implemented by the
12
+ // currently pinned testkit, so this guard is a no-op there; kept as defence against version skew.
13
+ // These hermetic flows drive completion directly and never resolve a form schema, so the fallback
14
+ // returns `null` — the documented "no matching form" path.
15
+ // - `openUserTasks` (issue #294 moved the pollers onto it): the CURRENT gap — the pinned testkit has
16
+ // `searchUserTasks` but not the open-task-scoped `openUserTasks`, so a poller call throws
17
+ // `not a function` (swallowed by the poller's try/catch) and the read-model denormalisation
18
+ // silently no-ops. Polyfill it as `searchUserTasks({ state: "CREATED" })` — byte-for-byte what
19
+ // urban's real `SdkEngineClient.openUserTasks` does, so the two cannot drift. Fix: nano-workforce
20
+ // #309; categorical (testkit) fix upstream in nanobpm/nano-ide#341.
11
21
  export function asEngineClient(engine: TestApp["engine"]): EngineClient {
12
- const e = engine as unknown as EngineClient & { getForm?: EngineClient["getForm"] };
22
+ const e = engine as unknown as EngineClient & {
23
+ getForm?: EngineClient["getForm"];
24
+ openUserTasks?: EngineClient["openUserTasks"];
25
+ };
13
26
  if (typeof e.getForm !== "function") {
14
27
  e.getForm = async () => null;
15
28
  }
29
+ if (typeof e.openUserTasks !== "function") {
30
+ e.openUserTasks = (filter) => e.searchUserTasks({ ...filter, state: "CREATED" });
31
+ }
16
32
  return e;
17
33
  }