@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
|
@@ -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
|
+
});
|