@nanobpm/nano-workforce 0.91.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.
@@ -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
  }
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
@@ -0,0 +1,130 @@
1
+ // Tests for the POST /app/api/actions/acknowledge-epic operation `acknowledgeEpic` (issue #298).
2
+ // The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (`delivery=landed`) or
3
+ // resolved-not-landed (`delivery=null`); only still-`converging` epics are rejected. It stamps
4
+ // `acknowledged_at` via the plans gateway, which recomputes `list_bucket` to 'history' (and
5
+ // `ack_open` to 0), dropping the resolved epic from Active into History. Unlike acknowledge-blocked
6
+ // it completes NO user task (a resolved epic is not parked). The epic twin of acknowledge-done.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import type { AppApi } from "@nanobpm/urban";
10
+ import { plans } from "../app/plan.ts";
11
+ import { noopLog } from "../test/log.ts";
12
+ import handler from "./acknowledgeEpic.ts";
13
+
14
+ // An in-memory data layer wired through the REAL plans gateway proxy, so the test exercises the
15
+ // gateway's list_bucket/ack_open projection exactly as production does.
16
+ function memApp(seed: any[]): { app: AppApi; rows: any[] } {
17
+ const stores: Record<string, any[]> = { plans: seed };
18
+ function tbl(name: string, pk = "id") {
19
+ const rows = (stores[name] ??= [] as any[]);
20
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
21
+ return {
22
+ async all() {
23
+ return rows.slice();
24
+ },
25
+ async get(id: any) {
26
+ return rows.find((r) => r[pk] === id);
27
+ },
28
+ async find(where: any = {}) {
29
+ return rows.filter((r) => match(r, where));
30
+ },
31
+ async insert(row: any) {
32
+ rows.push({ ...row });
33
+ return row[pk];
34
+ },
35
+ async update(id: any, patch: any) {
36
+ const r = rows.find((row) => row[pk] === id);
37
+ if (r) Object.assign(r, patch);
38
+ return r ? 1 : 0;
39
+ },
40
+ };
41
+ }
42
+ const app = {
43
+ data: { table: (n: string, pk?: string) => tbl(n, pk) },
44
+ log: noopLog(),
45
+ } as any as AppApi;
46
+ return { app, rows: stores.plans };
47
+ }
48
+
49
+ async function call(app: AppApi, body: unknown) {
50
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
51
+ }
52
+
53
+ test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history' on a landed epic", async () => {
54
+ const { app, rows } = memApp([{ plan_key: "o/r#1", status: "done", delivery: "landed", acknowledged_at: null }]);
55
+ // Seed the projection as the gateway would have on the last write (landed, unacknowledged → active).
56
+ await plans(app.data).update("o/r#1", { delivery: "landed" });
57
+ assertEquals(rows[0].list_bucket, "active");
58
+ assertEquals(rows[0].ack_open, 1);
59
+
60
+ const res = await call(app, { plan_key: "o/r#1" });
61
+
62
+ assertEquals(res.status, 200);
63
+ assertEquals(res.body.ok, true);
64
+ assertEquals(typeof rows[0].acknowledged_at, "string");
65
+ assertEquals(rows[0].list_bucket, "history");
66
+ assertEquals(rows[0].ack_open, 0);
67
+ });
68
+
69
+ test("acknowledge-epic: a still-converging epic is rejected (409) and stays Active", async () => {
70
+ const { app, rows } = memApp([{ plan_key: "o/r#2", status: "done", delivery: "converging", acknowledged_at: null }]);
71
+ await plans(app.data).update("o/r#2", { delivery: "converging" });
72
+
73
+ const res = await call(app, { plan_key: "o/r#2" });
74
+
75
+ assertEquals(res.status, 409);
76
+ assertEquals(res.body.ok, false);
77
+ // Untouched: no premature acknowledged_at, still Active.
78
+ assertEquals(rows[0].acknowledged_at, null);
79
+ assertEquals(rows[0].list_bucket, "active");
80
+ });
81
+
82
+ test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (200) and flips to History", async () => {
83
+ const { app, rows } = memApp([{ plan_key: "o/r#2b", status: "done", delivery: null, acknowledged_at: null }]);
84
+ // Seed the projection as the gateway would have on the last write (resolved-not-landed, unacknowledged → active).
85
+ await plans(app.data).update("o/r#2b", { delivery: null });
86
+ assertEquals(rows[0].list_bucket, "active");
87
+ assertEquals(rows[0].ack_open, 1);
88
+
89
+ const res = await call(app, { plan_key: "o/r#2b" });
90
+
91
+ assertEquals(res.status, 200);
92
+ assertEquals(res.body.ok, true);
93
+ assertEquals(typeof rows[0].acknowledged_at, "string");
94
+ assertEquals(rows[0].list_bucket, "history");
95
+ assertEquals(rows[0].ack_open, 0);
96
+ });
97
+
98
+ test("acknowledge-epic: a live (dispatched) epic is rejected (409)", async () => {
99
+ const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched", delivery: null, acknowledged_at: null }]);
100
+ const res = await call(app, { plan_key: "o/r#3" });
101
+ assertEquals(res.status, 409);
102
+ });
103
+
104
+ test("acknowledge-epic: a missing plan_key → 400", async () => {
105
+ const { app } = memApp([]);
106
+ const res = await call(app, {});
107
+ assertEquals(res.status, 400);
108
+ });
109
+
110
+ test("acknowledge-epic: no matching epic → 404", async () => {
111
+ const { app } = memApp([]);
112
+ const res = await call(app, { plan_key: "o/r#404" });
113
+ assertEquals(res.status, 404);
114
+ });
115
+
116
+ test("acknowledge-epic: idempotent — re-acknowledging a landed epic keeps it in History", async () => {
117
+ const { app, rows } = memApp([{ plan_key: "o/r#5", status: "done", delivery: "landed", acknowledged_at: null }]);
118
+ await plans(app.data).update("o/r#5", { delivery: "landed" });
119
+
120
+ assertEquals((await call(app, { plan_key: "o/r#5" })).status, 200);
121
+ const firstStamp = rows[0].acknowledged_at;
122
+ assertEquals(rows[0].list_bucket, "history");
123
+
124
+ const res2 = await call(app, { plan_key: "o/r#5" });
125
+ assertEquals(res2.status, 200);
126
+ assertEquals(rows[0].list_bucket, "history");
127
+ // Re-stamped (a fresh timestamp) but still resolved.
128
+ assertEquals(typeof rows[0].acknowledged_at, "string");
129
+ void firstStamp;
130
+ });
@@ -0,0 +1,63 @@
1
+ // POST /app/api/actions/acknowledge-epic → operationId `acknowledgeEpic` (issue #298).
2
+ // The nwf UI's "Dismiss" affordance for a RESOLVED epic: an operator dismisses a `done` epic whose
3
+ // fan-out has finished (all slice PRs reached a terminal state — whether all merged/landed or
4
+ // resolved-not-landed) directly from the Epic / Overview pages so it drops out of the Active epic list
5
+ // into History. It is the epic twin of `acknowledgeDone` (the feature-run tick-off) — a resolved epic
6
+ // is NOT parked at a user task, so this op completes no user task and touches no engine/ledger: it
7
+ // simply stamps `acknowledged_at` on the `plans` row via the plans gateway.
8
+ //
9
+ // The gateway (app/plan.ts) recomputes `list_bucket`/`ack_open` on that write — a landed, now-
10
+ // acknowledged epic flips `list_bucket` to 'history' and `ack_open` to 0 — so this op NEVER hand-sets
11
+ // a derived projection. Keyed on the row's `plan_key`. Idempotent-safe: re-acknowledging re-stamps
12
+ // the timestamp and keeps the row in History.
13
+ //
14
+ // It rejects (409) an epic that is NOT yet resolved — i.e. anything the `epicIsAcknowledgeable`
15
+ // guard refuses: a non-`done` status (`planning`/`dispatched`), or `done` but still `converging`. A
16
+ // resolved epic is acknowledgeable whether all slices merged (`delivery=landed`) or it resolved-not-
17
+ // landed (`delivery=null`); only a still-live or still-converging epic is refused, so those stay
18
+ // visible in Active and can never pre-seed the tick-off.
19
+
20
+ import { epicIsAcknowledgeable } from "../app/delivery.ts";
21
+ import { plans } from "../app/plan.ts";
22
+ import { defineOperation } from "../nano-generated/operations.ts";
23
+
24
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
25
+
26
+ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
27
+ if (!body || typeof body !== "object") {
28
+ app.log.warn("acknowledge-epic rejected: missing request body");
29
+ return { status: 400, body: { ok: false, error: "plan_key is required" } };
30
+ }
31
+
32
+ const planKey = str(body.plan_key);
33
+ if (!planKey) return { status: 400, body: { ok: false, error: "plan_key is required" } };
34
+
35
+ const table = plans(app.data);
36
+ const plan = await table.get(planKey);
37
+ if (!plan) {
38
+ app.log.warn("acknowledge-epic: no such plan", { planKey });
39
+ return { status: 404, body: { ok: false, error: "no such epic" } };
40
+ }
41
+
42
+ // Guard: only a RESOLVED epic (`status=done` and no longer `converging`) carries the Dismiss
43
+ // affordance. Acknowledging a live/converging epic would pre-seed `acknowledged_at`, so the moment
44
+ // it later resolved `deriveEpicBucket` would drop it straight into History, skipping the operator
45
+ // tick-off this op exists to require — and a converging epic must stay visible while its slices land.
46
+ if (!epicIsAcknowledgeable(plan.status, plan.delivery ?? null)) {
47
+ app.log.warn("acknowledge-epic rejected: epic is not resolved", {
48
+ planKey,
49
+ status: plan.status,
50
+ delivery: plan.delivery ?? null,
51
+ });
52
+ return { status: 409, body: { ok: false, error: "epic is not resolved" } };
53
+ }
54
+
55
+ // Stamp the dismissal. The gateway recomputes `list_bucket` (→ 'history') and `ack_open` (→ 0) from
56
+ // the merged row, so we never hand-set them here. Idempotent: re-acknowledging re-stamps and stays
57
+ // in History.
58
+ const now = new Date().toISOString();
59
+ await table.update(planKey, { acknowledged_at: now, updated_at: now });
60
+
61
+ app.log.info("operator dismissed resolved epic", { planKey });
62
+ return { status: 200, body: { ok: true, message: "acknowledged" } };
63
+ });