@nanobpm/nano-workforce 0.90.0 → 0.92.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 +19 -0
- package/SPEC.md +10 -4
- package/app/admittedStaging.test.ts +98 -0
- package/app/contracts.ts +10 -1
- package/app/delivery.ts +61 -0
- package/app/epicBucket.test.ts +57 -0
- package/app/epicSetValidation.test.ts +169 -0
- package/app/migration045.test.ts +97 -0
- package/app/plan.ts +401 -12
- package/app/planGateway.test.ts +106 -0
- package/app/pollUserTasks.test.ts +7 -2
- package/app/service.ts +18 -1
- package/app/userTasks.test.ts +24 -0
- package/app/userTasks.ts +11 -0
- package/db/migrations/043_user_tasks_subject_title.sql +24 -0
- package/db/migrations/044_plan_list_bucket.sql +32 -0
- package/db/migrations/045_epic_set_admission_staging.sql +56 -0
- package/e2e/support/engine-client.ts +24 -8
- package/openapi.yaml +245 -0
- package/operations/acknowledgeEpic.test.ts +130 -0
- package/operations/acknowledgeEpic.ts +63 -0
- package/operations/startEpicSet.admission.integration.test.ts +572 -0
- package/operations/startEpicSet.ts +217 -0
- package/operations/startPlanFanout.ts +5 -56
- package/package.json +1 -1
- package/pages/epic.page.json +15 -3
- package/pages/overview.page.json +13 -1
- package/pages/tasks.page.json +30 -10
- package/scripts/pages-contract.test.ts +17 -18
- package/workers/record-plan/worker.test.ts +1 -0
- package/workers/record-plan/worker.ts +2 -2
- package/workers/record-results/worker.test.ts +19 -1
- package/workers/record-results/worker.ts +3 -3
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";
|
|
@@ -1626,6 +1626,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1626
1626
|
elementId: FEATURE_ESCALATION_ELEMENT,
|
|
1627
1627
|
subjectType: "feature",
|
|
1628
1628
|
subjectKey: run.feature_key,
|
|
1629
|
+
subjectTitle: run.title,
|
|
1629
1630
|
subjectUrl: run.issue_url,
|
|
1630
1631
|
question: run.escalation_question,
|
|
1631
1632
|
processKey: run.process_key,
|
|
@@ -1642,6 +1643,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1642
1643
|
elementId: FEATURE_BLOCKED_ELEMENT,
|
|
1643
1644
|
subjectType: "feature",
|
|
1644
1645
|
subjectKey: run.feature_key,
|
|
1646
|
+
subjectTitle: run.title,
|
|
1645
1647
|
subjectUrl: run.issue_url,
|
|
1646
1648
|
question: run.delivery_label,
|
|
1647
1649
|
processKey: run.process_key,
|
|
@@ -1680,6 +1682,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1680
1682
|
elementId: PLAN_REVIEW_ELEMENT,
|
|
1681
1683
|
subjectType: "plan",
|
|
1682
1684
|
subjectKey: plan.plan_key,
|
|
1685
|
+
subjectTitle: plan.title,
|
|
1683
1686
|
subjectUrl: plan.issue_url,
|
|
1684
1687
|
question,
|
|
1685
1688
|
processKey: plan.process_key,
|
|
@@ -1696,6 +1699,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1696
1699
|
elementId: TRIAL_MERGE_ELEMENT,
|
|
1697
1700
|
subjectType: "plan",
|
|
1698
1701
|
subjectKey: plan.plan_key,
|
|
1702
|
+
subjectTitle: plan.title,
|
|
1699
1703
|
subjectUrl: plan.issue_url,
|
|
1700
1704
|
question,
|
|
1701
1705
|
processKey: plan.process_key,
|
|
@@ -1735,6 +1739,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1735
1739
|
elementId: t.elementId,
|
|
1736
1740
|
subjectType: "pr",
|
|
1737
1741
|
subjectKey: pr.pr_key,
|
|
1742
|
+
subjectTitle: pr.title,
|
|
1738
1743
|
subjectUrl: pr.url,
|
|
1739
1744
|
question,
|
|
1740
1745
|
processKey: pr.process_key,
|
|
@@ -1768,6 +1773,11 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1768
1773
|
* on every poll. */
|
|
1769
1774
|
let featureStagesBackfilled = false;
|
|
1770
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
|
+
|
|
1771
1781
|
export async function pollOnce(
|
|
1772
1782
|
data: DataLayer,
|
|
1773
1783
|
engine: EngineClient,
|
|
@@ -1784,6 +1794,13 @@ export async function pollOnce(
|
|
|
1784
1794
|
await backfillFeatureStages(data);
|
|
1785
1795
|
featureStagesBackfilled = true;
|
|
1786
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
|
+
}
|
|
1787
1804
|
await pollReviews(data, engine, token);
|
|
1788
1805
|
await pollMerges(data, engine, token);
|
|
1789
1806
|
await pollDelivery(data);
|
package/app/userTasks.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ test("buildUserTaskRow: a plan-review task becomes a labelled row with its findi
|
|
|
41
41
|
kind_label: "Plan review",
|
|
42
42
|
subject_type: "plan",
|
|
43
43
|
subject_key: "o/r#1",
|
|
44
|
+
subject_title: "o/r#1",
|
|
44
45
|
subject_url: "https://github.com/o/r/issues/1",
|
|
45
46
|
question: "cap reached: revise scope",
|
|
46
47
|
process_key: "pk-1",
|
|
@@ -80,6 +81,28 @@ test("buildUserTaskRow: a blank userTaskKey or subjectKey yields null", () => {
|
|
|
80
81
|
);
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
test("buildUserTaskRow: subject_title carries the subject title, trimmed, and coalesces to subject_key when absent/blank (issue #308)", () => {
|
|
85
|
+
const titled = buildUserTaskRow(
|
|
86
|
+
{
|
|
87
|
+
userTaskKey: "ut-t1",
|
|
88
|
+
elementId: PLAN_REVIEW_ELEMENT,
|
|
89
|
+
subjectType: "plan",
|
|
90
|
+
subjectKey: "o/r#1",
|
|
91
|
+
subjectTitle: " Add the widget ",
|
|
92
|
+
},
|
|
93
|
+
AT,
|
|
94
|
+
);
|
|
95
|
+
assertEquals(titled?.subject_title, "Add the widget");
|
|
96
|
+
|
|
97
|
+
for (const subjectTitle of [undefined, null, " "]) {
|
|
98
|
+
const row = buildUserTaskRow(
|
|
99
|
+
{ userTaskKey: "ut-t2", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#2", subjectTitle },
|
|
100
|
+
AT,
|
|
101
|
+
);
|
|
102
|
+
assertEquals(row?.subject_title, "o/r#2");
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
83
106
|
function row(key: string, extra: Partial<UserTaskRow> = {}): UserTaskRow {
|
|
84
107
|
return {
|
|
85
108
|
user_task_key: key,
|
|
@@ -87,6 +110,7 @@ function row(key: string, extra: Partial<UserTaskRow> = {}): UserTaskRow {
|
|
|
87
110
|
kind_label: "Plan review",
|
|
88
111
|
subject_type: "plan",
|
|
89
112
|
subject_key: "o/r#1",
|
|
113
|
+
subject_title: "o/r#1",
|
|
90
114
|
subject_url: null,
|
|
91
115
|
question: null,
|
|
92
116
|
process_key: null,
|
package/app/userTasks.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface UserTaskRow {
|
|
|
51
51
|
kind_label: string;
|
|
52
52
|
subject_type: string;
|
|
53
53
|
subject_key: string;
|
|
54
|
+
/** The subject's human-readable title (`feature_runs`/`plans`/`pull_requests`.`title`), derived by
|
|
55
|
+
* `pollUserTasks` from the subject row keyed on `subject_key` and coalesced to `subject_key` at
|
|
56
|
+
* build time so the title-led grids never render a blank primary line (issue #308). */
|
|
57
|
+
subject_title: string;
|
|
54
58
|
subject_url: string | null;
|
|
55
59
|
question: string | null;
|
|
56
60
|
process_key: string | null;
|
|
@@ -78,6 +82,10 @@ export interface UserTaskContext {
|
|
|
78
82
|
elementId: string;
|
|
79
83
|
subjectType: "feature" | "plan" | "pr";
|
|
80
84
|
subjectKey: string;
|
|
85
|
+
/** The subject's human-readable title from its own row (`feature_runs`/`plans`/`pull_requests`.
|
|
86
|
+
* `title`). Optional/blank tolerated — `buildUserTaskRow` coalesces it to `subjectKey` so the
|
|
87
|
+
* projected `subject_title` is never blank. */
|
|
88
|
+
subjectTitle?: string | null;
|
|
81
89
|
subjectUrl?: string | null;
|
|
82
90
|
question?: string | null;
|
|
83
91
|
processKey?: string | null;
|
|
@@ -93,12 +101,14 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
|
|
|
93
101
|
const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
|
|
94
102
|
if (!userTaskKey || !subjectKey || !kindLabel) return null;
|
|
95
103
|
const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
|
|
104
|
+
const subjectTitle = typeof ctx.subjectTitle === "string" && ctx.subjectTitle.trim() ? ctx.subjectTitle.trim() : subjectKey;
|
|
96
105
|
return {
|
|
97
106
|
user_task_key: userTaskKey,
|
|
98
107
|
element_id: ctx.elementId,
|
|
99
108
|
kind_label: kindLabel,
|
|
100
109
|
subject_type: ctx.subjectType,
|
|
101
110
|
subject_key: subjectKey,
|
|
111
|
+
subject_title: subjectTitle,
|
|
102
112
|
subject_url: ctx.subjectUrl ?? null,
|
|
103
113
|
question,
|
|
104
114
|
process_key: ctx.processKey ?? null,
|
|
@@ -124,6 +134,7 @@ function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
|
|
|
124
134
|
a.kind_label === b.kind_label &&
|
|
125
135
|
a.subject_type === b.subject_type &&
|
|
126
136
|
a.subject_key === b.subject_key &&
|
|
137
|
+
a.subject_title === b.subject_title &&
|
|
127
138
|
a.subject_url === b.subject_url &&
|
|
128
139
|
a.question === b.question &&
|
|
129
140
|
a.process_key === b.process_key
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Surface the subject's human-readable title as the primary identity on the Tasks
|
|
2
|
+
-- grids (issue #308), mirroring the Features list. Every `user_tasks` row was keyed
|
|
3
|
+
-- only by `subject_key` (`owner/repo#N`) — meaningful while you remember the number,
|
|
4
|
+
-- opaque hours later. The Features list already leads with `{{title}}` and shows the
|
|
5
|
+
-- key as a subtitle; the Tasks grids could only show the bare key.
|
|
6
|
+
--
|
|
7
|
+
-- The title is DERIVED (no new fetch): each escalation's subject already persists a
|
|
8
|
+
-- title in a surviving table keyed by `subject_key` — `feature_runs.title`,
|
|
9
|
+
-- `plans.title`, `pull_requests.title`. `pollUserTasks` reads it from the subject row
|
|
10
|
+
-- already in scope this pass and coalesces to `subject_key` at write time so the
|
|
11
|
+
-- column is ALWAYS non-blank — the grid's `{{subject_title}}` template then needs no
|
|
12
|
+
-- fallback and a missing subject title still shows a usable identity.
|
|
13
|
+
--
|
|
14
|
+
-- Forward-only, additive (expand): nullable with no default, then backfilled in the
|
|
15
|
+
-- same migration so the column is non-blank immediately — pre-#308 rows would otherwise
|
|
16
|
+
-- grandfather in as NULL and the grid's `{{subject_title}}` primary line would render a
|
|
17
|
+
-- blank/"null" identity for the window between deploy and the first `pollUserTasks` pass.
|
|
18
|
+
-- The backfill coalesces existing rows to `subject_key` (matching the write-time coalesce
|
|
19
|
+
-- in `pollUserTasks`, which re-derives the real title in place on the next poll — a
|
|
20
|
+
-- completed task's row is deleted, not migrated). Idempotent: re-running is a no-op once
|
|
21
|
+
-- set. Numbered after the current highest prefix on origin/main (042); the runner wraps
|
|
22
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
23
|
+
ALTER TABLE user_tasks ADD COLUMN subject_title TEXT;
|
|
24
|
+
UPDATE user_tasks SET subject_title = subject_key WHERE subject_title IS NULL OR trim(subject_title) = '';
|
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 & {
|
|
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
|
}
|
package/openapi.yaml
CHANGED
|
@@ -845,6 +845,159 @@ components:
|
|
|
845
845
|
alreadyRunning:
|
|
846
846
|
type: boolean
|
|
847
847
|
description: True when a non-terminal feature run for this issue already exists; no new instance was started.
|
|
848
|
+
EpicSetStart:
|
|
849
|
+
description: >-
|
|
850
|
+
The set/batch admission request body (issue #292, slice S2). Submits a SET of epics plus the
|
|
851
|
+
inter-epic dependency edges between them. `epics` is admitted all-or-nothing through the same
|
|
852
|
+
`admitPlan` gate as the single-issue door; `deps` declares that a `consumer` epic waits for a
|
|
853
|
+
`producer` epic's published `{ package, capabilityRef }` capability. Every edge must connect
|
|
854
|
+
two epics named in `epics`, and the edge set must be an acyclic DAG — otherwise the whole set
|
|
855
|
+
is rejected with a 4xx and nothing is persisted.
|
|
856
|
+
type: object
|
|
857
|
+
additionalProperties: false
|
|
858
|
+
required:
|
|
859
|
+
- epics
|
|
860
|
+
properties:
|
|
861
|
+
epics:
|
|
862
|
+
type: array
|
|
863
|
+
minItems: 1
|
|
864
|
+
description: The epics to admit as one set. Each is admitted through the `admitPlan` gate.
|
|
865
|
+
items:
|
|
866
|
+
$ref: "#/components/schemas/EpicSetMember"
|
|
867
|
+
deps:
|
|
868
|
+
type: array
|
|
869
|
+
description: >-
|
|
870
|
+
The inter-epic dependency edges. Each declares `consumer` waits for `producer` to publish
|
|
871
|
+
the `{ package, capabilityRef }` capability. Both endpoints must name epics in `epics`.
|
|
872
|
+
Omit or pass `[]` for a set of independent (root) epics.
|
|
873
|
+
items:
|
|
874
|
+
$ref: "#/components/schemas/EpicSetDep"
|
|
875
|
+
EpicSetMember:
|
|
876
|
+
description: >-
|
|
877
|
+
One epic in a submitted set. Names the target issue by EXACTLY ONE of `issue`
|
|
878
|
+
(`owner/repo#123`) or `url` (a bare issue URL), plus a REQUIRED `baseBranch` and the optional
|
|
879
|
+
admission acknowledgements — the same per-epic admission inputs as `PlanStart`.
|
|
880
|
+
oneOf:
|
|
881
|
+
- $ref: "#/components/schemas/EpicSetMemberByIssue"
|
|
882
|
+
- $ref: "#/components/schemas/EpicSetMemberByUrl"
|
|
883
|
+
EpicSetMemberByIssue:
|
|
884
|
+
type: object
|
|
885
|
+
additionalProperties: false
|
|
886
|
+
required:
|
|
887
|
+
- issue
|
|
888
|
+
- baseBranch
|
|
889
|
+
properties:
|
|
890
|
+
issue:
|
|
891
|
+
type: string
|
|
892
|
+
description: "Issue reference: owner/repo#123."
|
|
893
|
+
baseBranch:
|
|
894
|
+
type: string
|
|
895
|
+
minLength: 1
|
|
896
|
+
maxLength: 255
|
|
897
|
+
pattern: '\S'
|
|
898
|
+
description: >-
|
|
899
|
+
REQUIRED integration branch this epic branches off and opens its PRs against, admitted
|
|
900
|
+
through the same ADR 0003 policy as the single-issue door. See `PlanStartByIssue.baseBranch`.
|
|
901
|
+
allowSharedBase:
|
|
902
|
+
type: boolean
|
|
903
|
+
description: Opt in to sharing a custom integration base with another active epic. See `PlanStartByIssue.allowSharedBase`.
|
|
904
|
+
confirmDefaultBase:
|
|
905
|
+
type: boolean
|
|
906
|
+
description: Acknowledge that `baseBranch` names the repository default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
907
|
+
EpicSetMemberByUrl:
|
|
908
|
+
type: object
|
|
909
|
+
additionalProperties: false
|
|
910
|
+
required:
|
|
911
|
+
- url
|
|
912
|
+
- baseBranch
|
|
913
|
+
properties:
|
|
914
|
+
url:
|
|
915
|
+
type: string
|
|
916
|
+
description: A bare issue URL, when no `owner/repo#123` reference is supplied.
|
|
917
|
+
baseBranch:
|
|
918
|
+
type: string
|
|
919
|
+
minLength: 1
|
|
920
|
+
maxLength: 255
|
|
921
|
+
pattern: '\S'
|
|
922
|
+
description: REQUIRED integration branch this epic branches off. See `EpicSetMemberByIssue.baseBranch`.
|
|
923
|
+
allowSharedBase:
|
|
924
|
+
type: boolean
|
|
925
|
+
description: Share a custom integration base with another active epic. See `PlanStartByIssue.allowSharedBase`.
|
|
926
|
+
confirmDefaultBase:
|
|
927
|
+
type: boolean
|
|
928
|
+
description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
929
|
+
EpicSetDep:
|
|
930
|
+
description: >-
|
|
931
|
+
One inter-epic dependency edge: the `consumer` epic waits for the `producer` epic to publish
|
|
932
|
+
the `{ package, capabilityRef }` capability. `consumer`/`producer` are epic references
|
|
933
|
+
(`owner/repo#123` or an issue URL) that MUST both appear in the set's `epics`.
|
|
934
|
+
type: object
|
|
935
|
+
additionalProperties: false
|
|
936
|
+
required:
|
|
937
|
+
- consumer
|
|
938
|
+
- producer
|
|
939
|
+
- package
|
|
940
|
+
- capabilityRef
|
|
941
|
+
properties:
|
|
942
|
+
consumer:
|
|
943
|
+
type: string
|
|
944
|
+
description: The dependent epic (waits). An `owner/repo#123` reference or issue URL in the set.
|
|
945
|
+
producer:
|
|
946
|
+
type: string
|
|
947
|
+
description: The producer epic it waits for. An `owner/repo#123` reference or issue URL in the set.
|
|
948
|
+
package:
|
|
949
|
+
type: string
|
|
950
|
+
minLength: 1
|
|
951
|
+
description: The producer epic's published package name — the capability probe's target (S3).
|
|
952
|
+
capabilityRef:
|
|
953
|
+
type: string
|
|
954
|
+
minLength: 1
|
|
955
|
+
description: The producer epic's issue handle, used to resolve which published pkg@version first carries the capability (S3).
|
|
956
|
+
StartEpicSetResult:
|
|
957
|
+
description: The result of a successful set admission — the epics admitted and the edges staged (FK-free in `admitted_plan_deps`) for S3 to materialize.
|
|
958
|
+
type: object
|
|
959
|
+
required:
|
|
960
|
+
- epics
|
|
961
|
+
- roots
|
|
962
|
+
- edges
|
|
963
|
+
properties:
|
|
964
|
+
epics:
|
|
965
|
+
type: array
|
|
966
|
+
description: Every epic admitted, with its normalized base branch.
|
|
967
|
+
items:
|
|
968
|
+
type: object
|
|
969
|
+
required:
|
|
970
|
+
- planKey
|
|
971
|
+
- baseBranch
|
|
972
|
+
properties:
|
|
973
|
+
planKey:
|
|
974
|
+
type: string
|
|
975
|
+
baseBranch:
|
|
976
|
+
type: string
|
|
977
|
+
roots:
|
|
978
|
+
type: array
|
|
979
|
+
description: The plan keys of epics with NO inbound edge — the roots S3 starts immediately.
|
|
980
|
+
items:
|
|
981
|
+
type: string
|
|
982
|
+
edges:
|
|
983
|
+
type: array
|
|
984
|
+
description: The inter-epic edges staged FK-free into `admitted_plan_deps` for S3 to materialize into `plan_deps` (endpoints resolved to plan keys).
|
|
985
|
+
items:
|
|
986
|
+
type: object
|
|
987
|
+
required:
|
|
988
|
+
- consumer
|
|
989
|
+
- producer
|
|
990
|
+
- package
|
|
991
|
+
- capabilityRef
|
|
992
|
+
properties:
|
|
993
|
+
consumer:
|
|
994
|
+
type: string
|
|
995
|
+
producer:
|
|
996
|
+
type: string
|
|
997
|
+
package:
|
|
998
|
+
type: string
|
|
999
|
+
capabilityRef:
|
|
1000
|
+
type: string
|
|
848
1001
|
ConvergenceStart:
|
|
849
1002
|
description: The start-convergence request body. Names the target PR by EXACTLY ONE of `pr`
|
|
850
1003
|
(an `owner/repo#123` reference) or `url` (a bare PR URL) — never both, never neither — with
|
|
@@ -1620,6 +1773,47 @@ paths:
|
|
|
1620
1773
|
application/json:
|
|
1621
1774
|
schema:
|
|
1622
1775
|
$ref: "#/components/schemas/ErrorBody"
|
|
1776
|
+
/actions/start/epic-set:
|
|
1777
|
+
post:
|
|
1778
|
+
operationId: startEpicSet
|
|
1779
|
+
summary: Admit a SET of epics plus their inter-epic dependency edges in one all-or-nothing call (issue #292).
|
|
1780
|
+
description: >-
|
|
1781
|
+
The set/batch admission door (slice S2). Validates the WHOLE submission before persisting
|
|
1782
|
+
anything: each epic passes the same `admitPlan` gate as the single-issue door, every edge must
|
|
1783
|
+
connect two epics in the submitted set, and the edge set must be an acyclic DAG. A malformed
|
|
1784
|
+
set fails as one clean 4xx at the offending epic/edge with nothing half-started — no edge is
|
|
1785
|
+
persisted. On success the admitted epics and validated edges are staged FK-free into
|
|
1786
|
+
`admitted_epics` / `admitted_plan_deps`; materializing them into `plans` / `plan_deps` and
|
|
1787
|
+
scheduling/lowering (starting roots, seeding the capability readiness-gate, version binding)
|
|
1788
|
+
is a later slice (S3).
|
|
1789
|
+
Idempotent on the set: re-submitting the identical set records no duplicate edge.
|
|
1790
|
+
requestBody:
|
|
1791
|
+
required: true
|
|
1792
|
+
content:
|
|
1793
|
+
application/json:
|
|
1794
|
+
schema:
|
|
1795
|
+
$ref: "#/components/schemas/EpicSetStart"
|
|
1796
|
+
responses:
|
|
1797
|
+
"202":
|
|
1798
|
+
description: The whole set validated; every epic was admitted and every edge staged FK-free (in `admitted_plan_deps`) for S3 to materialize.
|
|
1799
|
+
content:
|
|
1800
|
+
application/json:
|
|
1801
|
+
schema:
|
|
1802
|
+
$ref: "#/components/schemas/StartEpicSetResult"
|
|
1803
|
+
"400":
|
|
1804
|
+
description: >-
|
|
1805
|
+
The set is malformed — an unparseable epic/edge reference, a rejected base branch, an edge
|
|
1806
|
+
naming an epic outside the set, or a dependency cycle.
|
|
1807
|
+
content:
|
|
1808
|
+
application/json:
|
|
1809
|
+
schema:
|
|
1810
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
1811
|
+
"409":
|
|
1812
|
+
description: An epic's base branch is already in use by another active epic (shared-base guard).
|
|
1813
|
+
content:
|
|
1814
|
+
application/json:
|
|
1815
|
+
schema:
|
|
1816
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
1623
1817
|
/actions/start/feature:
|
|
1624
1818
|
post:
|
|
1625
1819
|
operationId: startFeature
|
|
@@ -1898,6 +2092,57 @@ paths:
|
|
|
1898
2092
|
application/json:
|
|
1899
2093
|
schema:
|
|
1900
2094
|
$ref: "#/components/schemas/MessageResult"
|
|
2095
|
+
/actions/acknowledge-epic:
|
|
2096
|
+
post:
|
|
2097
|
+
operationId: acknowledgeEpic
|
|
2098
|
+
summary: "Dismiss a RESOLVED epic (issue #298). Stamps `acknowledged_at` on the `plans` row so
|
|
2099
|
+
the gateway recomputes its `list_bucket` to 'history' (and `ack_open` to 0), dropping the
|
|
2100
|
+
resolved epic out of the Active epic list into History. The epic twin of acknowledge-done, but
|
|
2101
|
+
a resolved epic is not parked at a user task, so this completes no user task and only writes the
|
|
2102
|
+
row. Keyed on the epic's `plan_key`. Rejects (409) an epic that is not yet resolved (still
|
|
2103
|
+
`planning`/`dispatched`, or `done` but still `converging`), so a converging epic stays visible
|
|
2104
|
+
in Active. Idempotent-safe (re-acknowledging keeps it in History)."
|
|
2105
|
+
requestBody:
|
|
2106
|
+
required: true
|
|
2107
|
+
content:
|
|
2108
|
+
application/json:
|
|
2109
|
+
schema:
|
|
2110
|
+
type: object
|
|
2111
|
+
additionalProperties: false
|
|
2112
|
+
required:
|
|
2113
|
+
- plan_key
|
|
2114
|
+
properties:
|
|
2115
|
+
plan_key:
|
|
2116
|
+
type: string
|
|
2117
|
+
minLength: 1
|
|
2118
|
+
description: The epic's key (plans.plan_key, `<owner>/<repo>#<n>`).
|
|
2119
|
+
responses:
|
|
2120
|
+
"200":
|
|
2121
|
+
description: The resolved epic was acknowledged and moved to History. Applies to any
|
|
2122
|
+
resolved epic — whether all slices merged (`delivery=landed`) or it resolved-not-landed
|
|
2123
|
+
(`delivery=null`), not only landed epics.
|
|
2124
|
+
content:
|
|
2125
|
+
application/json:
|
|
2126
|
+
schema:
|
|
2127
|
+
$ref: "#/components/schemas/MessageResult"
|
|
2128
|
+
"400":
|
|
2129
|
+
description: A required field (plan_key) was missing or invalid.
|
|
2130
|
+
content:
|
|
2131
|
+
application/json:
|
|
2132
|
+
schema:
|
|
2133
|
+
$ref: "#/components/schemas/MessageResult"
|
|
2134
|
+
"404":
|
|
2135
|
+
description: No epic matches the plan_key.
|
|
2136
|
+
content:
|
|
2137
|
+
application/json:
|
|
2138
|
+
schema:
|
|
2139
|
+
$ref: "#/components/schemas/MessageResult"
|
|
2140
|
+
"409":
|
|
2141
|
+
description: The epic is not yet resolved (still planning/dispatched, or converging), so it cannot be dismissed yet.
|
|
2142
|
+
content:
|
|
2143
|
+
application/json:
|
|
2144
|
+
schema:
|
|
2145
|
+
$ref: "#/components/schemas/MessageResult"
|
|
1901
2146
|
/hooks/agent-complete:
|
|
1902
2147
|
post:
|
|
1903
2148
|
operationId: agentCompleteEscalation
|