@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.
- 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/convergeGate.test.ts +80 -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/scopeGuard.test.ts +147 -0
- package/app/scopeGuard.ts +131 -0
- package/app/service.ts +13 -1
- 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/resources/prompts/feature.md +30 -1
- package/scripts/pages-contract.test.ts +17 -18
- package/workers/converge-gate/worker.ts +46 -2
- 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/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
|
+
});
|