@nanobpm/nano-workforce 0.120.2 → 0.122.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 +14 -0
- package/app/delivery.test.ts +76 -43
- package/app/lineage.test.ts +0 -5
- package/app/lineage.ts +24 -8
- package/app/mergesPerDay.test.ts +8 -92
- package/app/mergesPerDay.ts +7 -60
- package/app/plan.ts +26 -28
- package/app/planGateway.test.ts +25 -19
- package/app/service.ts +88 -45
- package/db/migrations/070_drop_plan_projection_columns.sql +31 -0
- package/db/migrations/071_drop_merges_per_day_table.sql +21 -0
- package/db/migrations/072_drop_lineage_view_backed_columns.sql +23 -0
- package/openapi.yaml +14 -0
- package/operations/acknowledgeEpic.test.ts +65 -16
- package/operations/acknowledgeEpic.ts +6 -2
- package/operations/previewDeliveryGraph.test.ts +9 -0
- package/operations/previewDeliveryGraph.ts +6 -0
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +273 -0
- package/pages/delivery-graphs/embed.html +32 -0
- package/pages/delivery-graphs/mount.js +365 -0
- package/pages/delivery-graphs/standalone.html +38 -0
- package/pages/delivery-graphs.page.json +7 -31
- package/scripts/pages-contract.test.ts +8 -16
- package/test/delivery-graphs-embed.test.ts +85 -0
- package/workers/record-plan/worker.test.ts +11 -9
- package/workers/record-plan/worker.ts +4 -8
- package/workers/record-wave/worker.test.ts +13 -11
- package/workers/record-wave/worker.ts +8 -12
- package/workers/select-wave/worker.test.ts +16 -14
- package/workers/select-wave/worker.ts +5 -6
package/app/planGateway.test.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
// Tests for the `plans` gateway's write-time epic-bucket projection (issue #298) and its one-shot
|
|
2
2
|
// backfill. The gateway wraps the plain table so EVERY writer — startPlan, the record workers, the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// acknowledge-epic op — automatically gets a fresh `list_bucket`/`ack_open` projection without
|
|
4
|
+
// passing them: the single write path is the only place `deriveEpicBucket` / `epicIsAcknowledgeable`
|
|
5
|
+
// are applied. Mirrors app/featureGateway.test.ts.
|
|
6
|
+
//
|
|
7
|
+
// Since epic #412 retired the stored `plans.delivery` column, the gateway projects with `delivery`
|
|
8
|
+
// treated as UNKNOWN (null): `list_bucket` is provably identical to the delivery-aware value for
|
|
9
|
+
// every reachable state, and `ack_open` becomes a *candidate* (any unacknowledged `done` epic) that
|
|
10
|
+
// the `pollPlanBucket` read-model pass (implemented in app/service.ts, tested in app/delivery.test.ts)
|
|
11
|
+
// corrects with the read-time signal.
|
|
6
12
|
import { test } from "node:test";
|
|
7
13
|
import { assert, assertEquals } from "#test-assert";
|
|
8
14
|
import type { DataLayer } from "@nanobpm/urban";
|
|
@@ -46,21 +52,20 @@ test("insert projects list_bucket/ack_open from status without the caller passin
|
|
|
46
52
|
assertEquals(rows[0].ack_open, 0);
|
|
47
53
|
});
|
|
48
54
|
|
|
49
|
-
test("status flip to done
|
|
55
|
+
test("status flip to done keeps the epic Active and marks a Dismiss candidate (delivery-free gateway)", async () => {
|
|
50
56
|
const { data, rows } = memData();
|
|
51
|
-
rows.push({ plan_key: "o/r#2", status: "dispatched"
|
|
52
|
-
// record-results marks the epic done
|
|
57
|
+
rows.push({ plan_key: "o/r#2", status: "dispatched" });
|
|
58
|
+
// record-results marks the epic done. The delivery-free gateway keeps it Active and — not seeing
|
|
59
|
+
// the read-time delivery signal — marks it a Dismiss candidate (ack_open=1); `pollPlanBucket`
|
|
60
|
+
// clears ack_open while the epic is still converging.
|
|
53
61
|
await plans(data).update("o/r#2", { status: "done" });
|
|
54
62
|
assertEquals(rows[0].list_bucket, "active");
|
|
55
|
-
|
|
56
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
57
|
-
assertEquals(rows[0].ack_open, 0);
|
|
63
|
+
assertEquals(rows[0].ack_open, 1);
|
|
58
64
|
});
|
|
59
65
|
|
|
60
|
-
test("
|
|
66
|
+
test("acknowledging a done epic flips it to History and closes the Dismiss affordance", async () => {
|
|
61
67
|
const { data, rows } = memData();
|
|
62
|
-
|
|
63
|
-
await plans(data).update("o/r#3", { delivery: "landed", delivery_label: "2/2 slices merged" });
|
|
68
|
+
await plans(data).insert({ plan_key: "o/r#3", status: "done" });
|
|
64
69
|
assertEquals(rows[0].list_bucket, "active");
|
|
65
70
|
assertEquals(rows[0].ack_open, 1);
|
|
66
71
|
// Operator dismisses → gateway reprojects to History, ack_open closes.
|
|
@@ -69,30 +74,31 @@ test("delivery landing opens the Dismiss affordance (ack_open=1) but keeps it Ac
|
|
|
69
74
|
assertEquals(rows[0].ack_open, 0);
|
|
70
75
|
});
|
|
71
76
|
|
|
72
|
-
test("a projection-irrelevant patch (
|
|
77
|
+
test("a projection-irrelevant patch (epic_phase only) does not disturb the stored bucket", async () => {
|
|
73
78
|
const { data, rows } = memData();
|
|
74
|
-
rows.push({ plan_key: "o/r#4", status: "done",
|
|
75
|
-
await plans(data).update("o/r#4", {
|
|
79
|
+
rows.push({ plan_key: "o/r#4", status: "done", list_bucket: "active", ack_open: 1 });
|
|
80
|
+
await plans(data).update("o/r#4", { epic_phase: "Finalizing" });
|
|
76
81
|
assertEquals(rows[0].list_bucket, "active");
|
|
77
82
|
assertEquals(rows[0].ack_open, 1);
|
|
78
83
|
});
|
|
79
84
|
|
|
80
85
|
test("a direct write to list_bucket is overridden by the canonical derivation (no bypass)", async () => {
|
|
81
86
|
const { data, rows } = memData();
|
|
82
|
-
rows.push({ plan_key: "o/r#5", status: "done"
|
|
83
|
-
// A caller tries to force History; the gateway re-derives from status
|
|
87
|
+
rows.push({ plan_key: "o/r#5", status: "done" });
|
|
88
|
+
// A caller tries to force History; the gateway re-derives from status (delivery-free) and overrides it.
|
|
84
89
|
await plans(data).update("o/r#5", { list_bucket: "history" });
|
|
85
90
|
assertEquals(rows[0].list_bucket, "active");
|
|
86
91
|
});
|
|
87
92
|
|
|
88
93
|
test("backfillPlanBuckets stamps only legacy (NULL list_bucket) rows, idempotently", async () => {
|
|
89
94
|
const { data, rows } = memData();
|
|
90
|
-
rows.push({ plan_key: "o/r#legacy", status: "done",
|
|
95
|
+
rows.push({ plan_key: "o/r#legacy", status: "done", list_bucket: null, ack_open: null });
|
|
91
96
|
rows.push({ plan_key: "o/r#fresh", status: "planning", list_bucket: "active", ack_open: 0 });
|
|
92
97
|
const stamped = await backfillPlanBuckets(data);
|
|
93
98
|
assertEquals(stamped, 1);
|
|
94
99
|
assertEquals(rows[0].list_bucket, "active");
|
|
95
|
-
|
|
100
|
+
// Delivery-free gateway marks the done epic a Dismiss candidate; pollPlanBucket corrects it later.
|
|
101
|
+
assertEquals(rows[0].ack_open, 1);
|
|
96
102
|
// Second pass is a no-op: every row is now projected.
|
|
97
103
|
assertEquals(await backfillPlanBuckets(data), 0);
|
|
98
104
|
});
|
package/app/service.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
conformanceEscalationQuestion,
|
|
28
28
|
} from "./conformance.ts";
|
|
29
29
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
30
|
-
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
30
|
+
import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable, TERMINAL_STATUSES } from "./delivery.ts";
|
|
31
31
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
32
32
|
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
33
33
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
@@ -52,19 +52,19 @@ import {
|
|
|
52
52
|
import { pollLineage } from "./lineage.ts";
|
|
53
53
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
54
54
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
55
|
-
import { pollMergesPerDay } from "./mergesPerDay.ts";
|
|
56
55
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
57
56
|
import {
|
|
58
57
|
backfillPlanBuckets,
|
|
59
58
|
capabilityGates,
|
|
60
59
|
inboundPlanDeps,
|
|
60
|
+
type Plan,
|
|
61
61
|
planReviews,
|
|
62
62
|
plans,
|
|
63
63
|
planTaskDeps,
|
|
64
64
|
planTaskNeeds,
|
|
65
65
|
planTasks,
|
|
66
66
|
} from "./plan.ts";
|
|
67
|
-
import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
67
|
+
import { derivePromotionState, isEpicIntegrationBranch, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
68
68
|
import {
|
|
69
69
|
defaultProbeExec,
|
|
70
70
|
type ProbeExec,
|
|
@@ -1862,54 +1862,75 @@ export async function pollCapabilityGatesImpl(
|
|
|
1862
1862
|
}
|
|
1863
1863
|
}
|
|
1864
1864
|
|
|
1865
|
-
/** Idempotent read-model pass: recompute each plan's derived `delivery` signal (issue #171) by
|
|
1866
|
-
* joining its slice tasks' `pr_key` → `pull_requests.status`, and denormalise it onto the `plans`
|
|
1867
|
-
* row so the epics overview / detail views can read it as a flat column (Urban's datasource can't
|
|
1868
|
-
* read a SQL VIEW). Never touches `plan.status` — additive/derived only. Writes only when the
|
|
1869
|
-
* projection actually changes, so a steady-state pass is a no-op. */
|
|
1870
1865
|
/** Sentinel status fed to `deriveDelivery` for a `plan_tasks.pr_key` whose `pull_requests` row is
|
|
1871
1866
|
* missing (DB desync). It is deliberately non-terminal and not `merged`, so a dangling PR counts as
|
|
1872
1867
|
* in-flight — never a false-positive `landed` from a silently-dropped slice. */
|
|
1873
1868
|
const MISSING_PR_STATUS = "missing";
|
|
1874
1869
|
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1870
|
+
/** Recompute an epic's derived `delivery` signal at READ TIME (epic #412) from the SAME pure
|
|
1871
|
+
* `deriveDelivery` the `plan_delivery` VIEW encodes — by joining each slice `plan_tasks.pr_key` →
|
|
1872
|
+
* `pull_requests.status` (a dangling `pr_key` counts as in-flight, never false-`landed`). The
|
|
1873
|
+
* `plans.delivery` column was RETIRED, so the pollers that still need the signal derive it here
|
|
1874
|
+
* rather than reading a denormalised column. Non-`done` plans short-circuit to `null` (the view's
|
|
1875
|
+
* behaviour) without the per-plan task join. `statusByPrKey` is an optional once-per-pass PR-status
|
|
1876
|
+
* map (the pollers preload it to avoid an N+1); when omitted (a one-off caller like the
|
|
1877
|
+
* acknowledge-epic op) it loads only THIS plan's slice PRs on demand — never the whole table. */
|
|
1878
|
+
export async function derivePlanDelivery(
|
|
1879
|
+
data: DataLayer,
|
|
1880
|
+
plan: Plan,
|
|
1881
|
+
statusByPrKey?: Map<string, string>,
|
|
1882
|
+
): Promise<string | null> {
|
|
1883
|
+
if (plan.status !== "done") return null;
|
|
1884
|
+
const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
|
|
1885
|
+
const prStatuses: string[] = [];
|
|
1886
|
+
for (const t of tasks) {
|
|
1887
|
+
if (!t.pr_key) continue;
|
|
1888
|
+
let status = statusByPrKey?.get(t.pr_key);
|
|
1889
|
+
if (status === undefined && !statusByPrKey) {
|
|
1890
|
+
// On-demand caller: fetch just this slice's PR row rather than loading the whole table.
|
|
1891
|
+
status = (await prs(data).get(t.pr_key))?.status;
|
|
1892
|
+
}
|
|
1893
|
+
prStatuses.push(status ?? MISSING_PR_STATUS);
|
|
1894
|
+
}
|
|
1895
|
+
return deriveDelivery(plan.status, prStatuses).delivery;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/** Idempotent read-model pass (epic #412 — successor to the retired `pollDelivery`): keep each
|
|
1899
|
+
* epic's Active/History `list_bucket` + `ack_open` tick-off flags fresh now that the `plans.delivery`
|
|
1900
|
+
* column is gone. The `plans` gateway projects both at write time, but with `delivery` treated as
|
|
1901
|
+
* UNKNOWN (null) — provably correct for `list_bucket`, but it cannot clear `ack_open` while a `done`
|
|
1902
|
+
* epic is still CONVERGING (its slices not all merged). This pass supplies the delivery-aware
|
|
1903
|
+
* correction: it recomputes `delivery` at read time via {@link derivePlanDelivery} and re-derives
|
|
1904
|
+
* `list_bucket`/`ack_open` from the pure `deriveEpicBucket`/`epicIsAcknowledgeable` helpers, writing
|
|
1905
|
+
* the result via the RAW `plans` table so the gateway's delivery-free reprojection can't clobber the
|
|
1906
|
+
* corrected value. Writes only when the projection actually changes, so a steady-state pass is a
|
|
1907
|
+
* no-op. Never touches `plan.status` — additive/derived only. */
|
|
1908
|
+
export async function pollPlanBucket(data: DataLayer) {
|
|
1909
|
+
// Preload every PR status once per pass into a pr_key→status map (avoids an N+1 `prs(data).get`).
|
|
1878
1910
|
const statusByPrKey = new Map<string, string>();
|
|
1879
1911
|
for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
|
|
1912
|
+
// Write through the RAW table, NOT the `plans` gateway: the gateway reprojects `list_bucket`/
|
|
1913
|
+
// `ack_open` with `delivery=null` (it can't see the read-time signal), which would undo the
|
|
1914
|
+
// delivery-aware `ack_open` correction below. This pass is the authoritative writer of the
|
|
1915
|
+
// delivery-aware bucket flags.
|
|
1916
|
+
const plansTable = data.table<Plan>("plans", "plan_key");
|
|
1880
1917
|
for (const plan of await plans(data).all()) {
|
|
1881
1918
|
try {
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
}
|
|
1893
|
-
continue;
|
|
1894
|
-
}
|
|
1895
|
-
const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
|
|
1896
|
-
const prStatuses: string[] = [];
|
|
1897
|
-
for (const t of tasks) {
|
|
1898
|
-
if (!t.pr_key) continue;
|
|
1899
|
-
// A dangling pr_key (row missing) is treated as in-flight, not dropped, so a DB desync
|
|
1900
|
-
// can never wrongly promote an epic to `landed`.
|
|
1901
|
-
prStatuses.push(statusByPrKey.get(t.pr_key) ?? MISSING_PR_STATUS);
|
|
1902
|
-
}
|
|
1903
|
-
const { delivery, label } = deriveDelivery(plan.status, prStatuses);
|
|
1904
|
-
if (plan.delivery !== delivery || plan.delivery_label !== label) {
|
|
1905
|
-
await plans(data).update(plan.plan_key, {
|
|
1906
|
-
delivery,
|
|
1907
|
-
delivery_label: label,
|
|
1919
|
+
const delivery = await derivePlanDelivery(data, plan, statusByPrKey);
|
|
1920
|
+
const listBucket = deriveEpicBucket(plan.status, delivery, plan.acknowledged_at ?? null);
|
|
1921
|
+
const ackOpen =
|
|
1922
|
+
epicIsAcknowledgeable(plan.status, delivery) && (plan.acknowledged_at ?? null) === null
|
|
1923
|
+
? 1
|
|
1924
|
+
: 0;
|
|
1925
|
+
if (plan.list_bucket !== listBucket || plan.ack_open !== ackOpen) {
|
|
1926
|
+
await plansTable.update(plan.plan_key, {
|
|
1927
|
+
list_bucket: listBucket,
|
|
1928
|
+
ack_open: ackOpen,
|
|
1908
1929
|
updated_at: now(),
|
|
1909
1930
|
});
|
|
1910
1931
|
}
|
|
1911
1932
|
} catch (err) {
|
|
1912
|
-
console.error(`[poller]
|
|
1933
|
+
console.error(`[poller] plan-bucket ${plan.plan_key}: ${err}`);
|
|
1913
1934
|
}
|
|
1914
1935
|
}
|
|
1915
1936
|
}
|
|
@@ -1926,12 +1947,30 @@ export async function pollDelivery(data: DataLayer) {
|
|
|
1926
1947
|
* a prior edge (e.g. an edge later removed) is cleared defensively so the read model never keeps a
|
|
1927
1948
|
* phantom gate. */
|
|
1928
1949
|
export async function pollWaitGate(data: DataLayer) {
|
|
1950
|
+
// Preload every plan_tasks row once per pass and group by plan_key, so deriving each plan's
|
|
1951
|
+
// fanned-out signal below is a map lookup rather than a per-plan `planTasks(data).find` (avoids
|
|
1952
|
+
// an N+1 that would double this pass's DB work alongside the per-plan `inboundPlanDeps` lookup).
|
|
1953
|
+
const wavesByPlanKey = new Map<string, number[]>();
|
|
1954
|
+
for (const t of await planTasks(data).all()) {
|
|
1955
|
+
if (t.wave == null) continue;
|
|
1956
|
+
const list = wavesByPlanKey.get(t.plan_key) ?? [];
|
|
1957
|
+
list.push(t.wave);
|
|
1958
|
+
wavesByPlanKey.set(t.plan_key, list);
|
|
1959
|
+
}
|
|
1929
1960
|
for (const plan of await plans(data).all()) {
|
|
1930
1961
|
try {
|
|
1931
1962
|
const edges = await inboundPlanDeps(data, plan.plan_key);
|
|
1963
|
+
// `plans.current_wave` was retired (epic #412); `deriveWaitGate` only consumes its
|
|
1964
|
+
// NULL-ness (proof the epic's fan-out began). Derive that at read time: an epic has fanned out
|
|
1965
|
+
// iff it has ≥1 levelized `plan_tasks` row (a wave assigned). The value's magnitude is never
|
|
1966
|
+
// surfaced here — the epic index/detail read the display `current_wave` off the
|
|
1967
|
+
// `plan_wave_label`/`plan_read_model` VIEWs — so any non-null (the frontier's min wave) is
|
|
1968
|
+
// faithful for the gate's "has this epic ever fanned out?" test.
|
|
1969
|
+
const assignedWaves = wavesByPlanKey.get(plan.plan_key) ?? [];
|
|
1970
|
+
const current_wave = assignedWaves.length > 0 ? Math.min(...assignedWaves) : null;
|
|
1932
1971
|
const { wait_gate, wait_gate_label } = deriveWaitGate(edges, {
|
|
1933
1972
|
status: plan.status,
|
|
1934
|
-
current_wave
|
|
1973
|
+
current_wave,
|
|
1935
1974
|
bound_artifacts: plan.bound_artifacts,
|
|
1936
1975
|
created_at: plan.created_at,
|
|
1937
1976
|
});
|
|
@@ -1951,8 +1990,8 @@ export async function pollWaitGate(data: DataLayer) {
|
|
|
1951
1990
|
/** Idempotent promotion pass (issue #299): open — and then track — the `epic/* → <default>`
|
|
1952
1991
|
* promotion PR for every epic that has LANDED on a custom integration branch. This is the missing
|
|
1953
1992
|
* counterpart to `ensureBaseBranch`: that creates the `epic/*` branch slices merge into; this
|
|
1954
|
-
* delivers the fully-landed branch to the default branch.
|
|
1955
|
-
*
|
|
1993
|
+
* delivers the fully-landed branch to the default branch. Derives each epic's `delivery` signal at
|
|
1994
|
+
* read time (epic #412 — the `plans.delivery` column was retired) via the same pure `deriveDelivery`.
|
|
1956
1995
|
*
|
|
1957
1996
|
* Per promotable plan (`isPromotable`: `delivery = landed` AND base is `epic/*`):
|
|
1958
1997
|
* • No promotion PR yet → open ONE `epic/* → <default>` PR (idempotent against a remote head-branch
|
|
@@ -1972,10 +2011,15 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
|
|
|
1972
2011
|
const statusByPrKey = new Map<string, string>();
|
|
1973
2012
|
for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
|
|
1974
2013
|
for (const plan of await plans(data).all()) {
|
|
1975
|
-
if (!isPromotable(plan)) continue;
|
|
1976
2014
|
const base = plan.base_branch;
|
|
1977
|
-
|
|
2015
|
+
// A non-`epic/*` base is never promotable — short-circuit before the per-plan delivery join.
|
|
2016
|
+
if (!isEpicIntegrationBranch(base)) continue;
|
|
1978
2017
|
try {
|
|
2018
|
+
// `plans.delivery` was retired (epic #412) — derive the landed signal at READ TIME from the
|
|
2019
|
+
// slice PRs (same pure `deriveDelivery` the `plan_delivery` VIEW encodes) instead of reading a
|
|
2020
|
+
// denormalised column, then apply the pure `isPromotable` predicate.
|
|
2021
|
+
const delivery = await derivePlanDelivery(data, plan, statusByPrKey);
|
|
2022
|
+
if (!isPromotable({ delivery, base_branch: base })) continue;
|
|
1979
2023
|
// Already opened → project state from the promotion PR's live status, and re-enroll it if its
|
|
1980
2024
|
// convergence row went missing (a prior submit failed, or the app/engine store desynced).
|
|
1981
2025
|
if (plan.promotion_pr) {
|
|
@@ -2433,12 +2477,11 @@ export async function pollOnce(
|
|
|
2433
2477
|
}
|
|
2434
2478
|
await pollReviews(data, engine, token);
|
|
2435
2479
|
await pollMerges(data, engine, token);
|
|
2436
|
-
await
|
|
2480
|
+
await pollPlanBucket(data);
|
|
2437
2481
|
await pollWaitGate(data);
|
|
2438
2482
|
await pollPromotion(data, engine, token);
|
|
2439
2483
|
await pollFeatureDelivery(data);
|
|
2440
2484
|
await pollLineage(data);
|
|
2441
|
-
await pollMergesPerDay(data);
|
|
2442
2485
|
await pollUserTasks(data, engine, engineRest);
|
|
2443
2486
|
await pollDeliveryGraphPhase(data, engine);
|
|
2444
2487
|
if (engineRest) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Contract phase for the worker-maintained `plans` projection columns (epic #412: retire
|
|
2
|
+
-- worker-maintained denormalised projections in favour of SQL VIEWs).
|
|
3
|
+
--
|
|
4
|
+
-- Wave-0 of this epic re-expressed every one of these denormalised `plans` columns as a DERIVED SQL
|
|
5
|
+
-- VIEW computed from the SAME pure helpers that used to feed the write-path, and repointed every
|
|
6
|
+
-- operator page onto those views:
|
|
7
|
+
-- • `wave_count` / `current_wave` / `wave_label` (022_plan_wave_progress.sql) → `plan_wave_progress`
|
|
8
|
+
-- / `plan_wave_label` / `plan_read_model` (060/061), derived from the levelized `plan_tasks`.
|
|
9
|
+
-- • `delivery` / `delivery_label` (029_plan_delivery.sql) → `plan_delivery` / `plan_read_model`
|
|
10
|
+
-- (061), derived via the pure `deriveDelivery`/`TERMINAL_STATUSES` (app/delivery.ts) by joining
|
|
11
|
+
-- each slice `plan_tasks.pr_key` → `pull_requests.status`.
|
|
12
|
+
--
|
|
13
|
+
-- This wave-1 cleanup deletes the now-dead write-paths (the `record-plan`/`record-wave`/`select-wave`
|
|
14
|
+
-- worker writes and the `pollDelivery` poller) and repoints the last internal readers onto read-time
|
|
15
|
+
-- derivation (`pollPlanBucket`/`pollPromotion` recompute `delivery` via `deriveDelivery`;
|
|
16
|
+
-- `pollWaitGate` derives "has the epic fanned out?" from `plan_tasks`). With no remaining writer or
|
|
17
|
+
-- reader, these five columns are dead schema — a drift surface with no source of truth behind them —
|
|
18
|
+
-- so drop them. The `plan_read_model` / `plan_wave_label` / `plan_delivery` VIEWs DERIVE their
|
|
19
|
+
-- wave/delivery columns from the join surfaces, never from these `plans` columns, so the pages the
|
|
20
|
+
-- views back are unaffected.
|
|
21
|
+
--
|
|
22
|
+
-- Forward-only contract migration numbered in this task's disjoint 070-079 block (029/022 are
|
|
23
|
+
-- forward-only and immutable, so this is the standard expand→contract follow-up, not an edit to
|
|
24
|
+
-- them). All five columns were nullable with no default and have no remaining writer or reader, and
|
|
25
|
+
-- no index references them, so a plain `ALTER TABLE … DROP COLUMN` suffices. The runner wraps each
|
|
26
|
+
-- file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
27
|
+
ALTER TABLE plans DROP COLUMN wave_count;
|
|
28
|
+
ALTER TABLE plans DROP COLUMN current_wave;
|
|
29
|
+
ALTER TABLE plans DROP COLUMN wave_label;
|
|
30
|
+
ALTER TABLE plans DROP COLUMN delivery;
|
|
31
|
+
ALTER TABLE plans DROP COLUMN delivery_label;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- Contract phase for the worker-maintained `merges_per_day` read table (epic #412: retire
|
|
2
|
+
-- worker-maintained denormalised projections in favour of SQL VIEWs).
|
|
3
|
+
--
|
|
4
|
+
-- 051_merges_per_day.sql created the flat `merges_per_day` read table the Velocity page bound, kept
|
|
5
|
+
-- fresh each poll pass by `pollMergesPerDay` (app/mergesPerDay.ts) projecting the pure
|
|
6
|
+
-- `deriveMergesPerDay` over the `merges` audit trail. Wave-0 re-expressed that aggregate as the
|
|
7
|
+
-- DERIVED `merges_per_day_view` VIEW (062) — computed directly from `merges` with the same
|
|
8
|
+
-- `COUNT(DISTINCT pr_key)` / local-day bucketing — and repointed `pages/velocity.page.json` onto it.
|
|
9
|
+
--
|
|
10
|
+
-- This wave-1 cleanup deletes the `pollMergesPerDay` write-path and its base-table accessors. With no
|
|
11
|
+
-- remaining writer or reader, the `merges_per_day` table is dead schema, so drop it. The pure
|
|
12
|
+
-- `deriveMergesPerDay`/`barFor` helpers (app/mergesPerDay.ts) stay — they are the single source of
|
|
13
|
+
-- truth the view's SQL mirrors and are still exercised by the view's read-model guard. The
|
|
14
|
+
-- `merges_per_day_view` reads the `merges` audit table, NOT this dropped table, so the Velocity page
|
|
15
|
+
-- is unaffected.
|
|
16
|
+
--
|
|
17
|
+
-- Forward-only contract migration numbered in this task's disjoint 070-079 block. The table has no
|
|
18
|
+
-- remaining writer or reader and nothing references it by foreign key, so a plain `DROP TABLE`
|
|
19
|
+
-- suffices. The runner wraps each file in its own transaction, so this file must NOT contain
|
|
20
|
+
-- BEGIN/COMMIT.
|
|
21
|
+
DROP TABLE merges_per_day;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- Contract phase for the view-migrated `lineage_threads` identity columns (epic #412: retire
|
|
2
|
+
-- worker-maintained denormalised projections in favour of SQL VIEWs).
|
|
3
|
+
--
|
|
4
|
+
-- 064_lineage_thread_view.sql introduced the `lineage_thread_view` VIEW the Lineage page now binds.
|
|
5
|
+
-- The lineage audit (lineage-views-audit) split `lineage_threads`' columns into two groups:
|
|
6
|
+
-- • VIEW-BACKED (fully derivable from the `plans`/`feature_runs` origin joins): `kind` and
|
|
7
|
+
-- `issue_url`. The view DERIVES both — `kind` from which origin table the root matches, `issue_url`
|
|
8
|
+
-- from that origin's `issue_url` (NULL for a self-rooted PR) — and NEVER reads `lt.kind` /
|
|
9
|
+
-- `lt.issue_url`. `pollLineage` (app/lineage.ts) no longer writes them (this wave-1 cleanup).
|
|
10
|
+
-- • PROCEDURAL (kept, still poller-written, still read by the view): `title` (a self-rooted PR's
|
|
11
|
+
-- representative-PR pick), `stage`, `stage_label`, `process_key`, `pr_keys`, `pr_count`, `active`,
|
|
12
|
+
-- `created_at`, `updated_at`. These are NOT dropped — the view passes them through from
|
|
13
|
+
-- `lineage_threads`, and `pollLineage` remains their single writer.
|
|
14
|
+
--
|
|
15
|
+
-- So drop ONLY the two fully view-backed columns; the procedural remainder stays. Both dropped
|
|
16
|
+
-- columns were nullable with no default and now have no writer or reader, and no index references
|
|
17
|
+
-- them, so a plain `ALTER TABLE … DROP COLUMN` suffices. Because a procedural remainder survives, the
|
|
18
|
+
-- `lineage_threads` table itself is NOT dropped.
|
|
19
|
+
--
|
|
20
|
+
-- Forward-only contract migration numbered in this task's disjoint 070-079 block. The runner wraps
|
|
21
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
22
|
+
ALTER TABLE lineage_threads DROP COLUMN kind;
|
|
23
|
+
ALTER TABLE lineage_threads DROP COLUMN issue_url;
|
package/openapi.yaml
CHANGED
|
@@ -1616,6 +1616,20 @@ components:
|
|
|
1616
1616
|
diagram:
|
|
1617
1617
|
type: string
|
|
1618
1618
|
description: The mermaid flowchart of the compiled graph (preview).
|
|
1619
|
+
humanNodes:
|
|
1620
|
+
type: array
|
|
1621
|
+
items:
|
|
1622
|
+
$ref: "#/components/schemas/DeliveryHumanStop"
|
|
1623
|
+
description: >-
|
|
1624
|
+
The human stop-points the compiled graph parks on (preview) — where it waits for a person
|
|
1625
|
+
(or an agent answering on their behalf), rendered by the Delivery Graphs page (#441).
|
|
1626
|
+
sideEffects:
|
|
1627
|
+
type: array
|
|
1628
|
+
items:
|
|
1629
|
+
$ref: "#/components/schemas/DeliverySideEffect"
|
|
1630
|
+
description: >-
|
|
1631
|
+
The side-effecting (`agent`/`connector`) actions the compiled graph WILL perform (preview)
|
|
1632
|
+
— what an approval authorises (Decision 7), rendered by the Delivery Graphs page (#441).
|
|
1619
1633
|
ResolvedDeliveryNode:
|
|
1620
1634
|
description: >-
|
|
1621
1635
|
A normalised node in the compiled graph (ADR 0005 slice S1) — its `id`, `kind`, the
|
|
@@ -1,9 +1,14 @@
|
|
|
1
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 (
|
|
3
|
-
// resolved-not-landed (
|
|
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
4
|
// `acknowledged_at` via the plans gateway, which recomputes `list_bucket` to 'history' (and
|
|
5
5
|
// `ack_open` to 0), dropping the resolved epic from Active into History. Unlike acknowledge-blocked
|
|
6
6
|
// it completes NO user task (a resolved epic is not parked). The epic twin of acknowledge-done.
|
|
7
|
+
//
|
|
8
|
+
// Since epic #412 retired the stored `plans.delivery` column, the op derives the delivery signal at
|
|
9
|
+
// READ TIME (`derivePlanDelivery` → the pure `deriveDelivery`) by joining the epic's slice
|
|
10
|
+
// `plan_tasks.pr_key` → `pull_requests.status`. So these tests seed `plan_tasks` + `pull_requests`
|
|
11
|
+
// (not a `plans.delivery` column) to model a landed / converging / resolved-not-landed epic.
|
|
7
12
|
import { test } from "node:test";
|
|
8
13
|
import { assertEquals } from "#test-assert";
|
|
9
14
|
import type { AppApi } from "@nanobpm/urban";
|
|
@@ -12,9 +17,13 @@ import { noopLog } from "../test/log.ts";
|
|
|
12
17
|
import handler from "./acknowledgeEpic.ts";
|
|
13
18
|
|
|
14
19
|
// 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
|
-
|
|
17
|
-
|
|
20
|
+
// gateway's list_bucket/ack_open projection exactly as production does. `extra` seeds the join
|
|
21
|
+
// surfaces (`plan_tasks` / `pull_requests`) the read-time delivery derivation reads.
|
|
22
|
+
function memApp(
|
|
23
|
+
seed: any[],
|
|
24
|
+
extra: Record<string, any[]> = {},
|
|
25
|
+
): { app: AppApi; rows: any[] } {
|
|
26
|
+
const stores: Record<string, any[]> = { plans: seed, ...extra };
|
|
18
27
|
function tbl(name: string, pk = "id") {
|
|
19
28
|
const rows = (stores[name] ??= [] as any[]);
|
|
20
29
|
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
@@ -50,10 +59,21 @@ async function call(app: AppApi, body: unknown) {
|
|
|
50
59
|
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
/** Fire a projecting gateway write (re-writing `status`) so `list_bucket`/`ack_open` reflect the
|
|
63
|
+
* delivery-free gateway projection, mirroring the last real write before the op runs. */
|
|
64
|
+
async function project(app: AppApi, key: string, status: string) {
|
|
65
|
+
await plans(app.data).update(key, { status });
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history' on a landed epic", async () => {
|
|
54
|
-
const { app, rows } = memApp(
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
const { app, rows } = memApp(
|
|
70
|
+
[{ plan_key: "o/r#1", status: "done", acknowledged_at: null }],
|
|
71
|
+
{
|
|
72
|
+
plan_tasks: [{ id: 1, plan_key: "o/r#1", pr_key: "o/r#100" }],
|
|
73
|
+
pull_requests: [{ pr_key: "o/r#100", status: "merged" }], // landed
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
await project(app, "o/r#1", "done");
|
|
57
77
|
assertEquals(rows[0].list_bucket, "active");
|
|
58
78
|
assertEquals(rows[0].ack_open, 1);
|
|
59
79
|
|
|
@@ -67,8 +87,20 @@ test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history
|
|
|
67
87
|
});
|
|
68
88
|
|
|
69
89
|
test("acknowledge-epic: a still-converging epic is rejected (409) and stays Active", async () => {
|
|
70
|
-
const { app, rows } = memApp(
|
|
71
|
-
|
|
90
|
+
const { app, rows } = memApp(
|
|
91
|
+
[{ plan_key: "o/r#2", status: "done", acknowledged_at: null }],
|
|
92
|
+
{
|
|
93
|
+
plan_tasks: [
|
|
94
|
+
{ id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
|
|
95
|
+
{ id: 2, plan_key: "o/r#2", pr_key: "o/r#201" },
|
|
96
|
+
],
|
|
97
|
+
pull_requests: [
|
|
98
|
+
{ pr_key: "o/r#200", status: "merged" },
|
|
99
|
+
{ pr_key: "o/r#201", status: "converging" }, // still in flight → converging
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
await project(app, "o/r#2", "done");
|
|
72
104
|
|
|
73
105
|
const res = await call(app, { plan_key: "o/r#2" });
|
|
74
106
|
|
|
@@ -80,9 +112,20 @@ test("acknowledge-epic: a still-converging epic is rejected (409) and stays Acti
|
|
|
80
112
|
});
|
|
81
113
|
|
|
82
114
|
test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (200) and flips to History", async () => {
|
|
83
|
-
const { app, rows } = memApp(
|
|
84
|
-
|
|
85
|
-
|
|
115
|
+
const { app, rows } = memApp(
|
|
116
|
+
[{ plan_key: "o/r#2b", status: "done", acknowledged_at: null }],
|
|
117
|
+
{
|
|
118
|
+
plan_tasks: [
|
|
119
|
+
{ id: 1, plan_key: "o/r#2b", pr_key: "o/r#210" },
|
|
120
|
+
{ id: 2, plan_key: "o/r#2b", pr_key: "o/r#211" },
|
|
121
|
+
],
|
|
122
|
+
pull_requests: [
|
|
123
|
+
{ pr_key: "o/r#210", status: "merged" },
|
|
124
|
+
{ pr_key: "o/r#211", status: "abandoned" }, // all terminal, not all merged → delivery null
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
await project(app, "o/r#2b", "done");
|
|
86
129
|
assertEquals(rows[0].list_bucket, "active");
|
|
87
130
|
assertEquals(rows[0].ack_open, 1);
|
|
88
131
|
|
|
@@ -96,7 +139,7 @@ test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (
|
|
|
96
139
|
});
|
|
97
140
|
|
|
98
141
|
test("acknowledge-epic: a live (dispatched) epic is rejected (409)", async () => {
|
|
99
|
-
const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched",
|
|
142
|
+
const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched", acknowledged_at: null }]);
|
|
100
143
|
const res = await call(app, { plan_key: "o/r#3" });
|
|
101
144
|
assertEquals(res.status, 409);
|
|
102
145
|
});
|
|
@@ -114,8 +157,14 @@ test("acknowledge-epic: no matching epic → 404", async () => {
|
|
|
114
157
|
});
|
|
115
158
|
|
|
116
159
|
test("acknowledge-epic: idempotent — re-acknowledging a landed epic keeps it in History", async () => {
|
|
117
|
-
const { app, rows } = memApp(
|
|
118
|
-
|
|
160
|
+
const { app, rows } = memApp(
|
|
161
|
+
[{ plan_key: "o/r#5", status: "done", acknowledged_at: null }],
|
|
162
|
+
{
|
|
163
|
+
plan_tasks: [{ id: 1, plan_key: "o/r#5", pr_key: "o/r#500" }],
|
|
164
|
+
pull_requests: [{ pr_key: "o/r#500", status: "merged" }], // landed
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
await project(app, "o/r#5", "done");
|
|
119
168
|
|
|
120
169
|
assertEquals((await call(app, { plan_key: "o/r#5" })).status, 200);
|
|
121
170
|
const firstStamp = rows[0].acknowledged_at;
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { epicIsAcknowledgeable } from "../app/delivery.ts";
|
|
21
21
|
import { plans } from "../app/plan.ts";
|
|
22
|
+
import { derivePlanDelivery } from "../app/service.ts";
|
|
22
23
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
23
24
|
|
|
24
25
|
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
@@ -43,11 +44,14 @@ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
|
|
|
43
44
|
// affordance. Acknowledging a live/converging epic would pre-seed `acknowledged_at`, so the moment
|
|
44
45
|
// it later resolved `deriveEpicBucket` would drop it straight into History, skipping the operator
|
|
45
46
|
// tick-off this op exists to require — and a converging epic must stay visible while its slices land.
|
|
46
|
-
|
|
47
|
+
// The `plans.delivery` column was retired (epic #412), so derive the signal at read time from the
|
|
48
|
+
// slice PRs (the same pure `deriveDelivery` the `plan_delivery` VIEW encodes).
|
|
49
|
+
const delivery = await derivePlanDelivery(app.data, plan);
|
|
50
|
+
if (!epicIsAcknowledgeable(plan.status, delivery)) {
|
|
47
51
|
app.log.warn("acknowledge-epic rejected: epic is not resolved", {
|
|
48
52
|
planKey,
|
|
49
53
|
status: plan.status,
|
|
50
|
-
delivery
|
|
54
|
+
delivery,
|
|
51
55
|
});
|
|
52
56
|
return { status: 409, body: { ok: false, error: "epic is not resolved" } };
|
|
53
57
|
}
|
|
@@ -35,6 +35,15 @@ test("preview-delivery-graph: a pasted well-formed graph → 200 summary with di
|
|
|
35
35
|
assertEquals(res.body.sideEffecting, true);
|
|
36
36
|
assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
|
|
37
37
|
assertEquals(res.body.title, "runbook");
|
|
38
|
+
// The FULL preview detail (#441) — the human stop-points and side-effecting actions the page
|
|
39
|
+
// renders, not just the counts. `a` is the side-effecting agent node; `b` is the human stop.
|
|
40
|
+
assert(Array.isArray(res.body.humanNodes) && res.body.humanNodes.length === 1);
|
|
41
|
+
assertEquals(res.body.humanNodes[0].nodeId, "b");
|
|
42
|
+
assertEquals(res.body.humanNodes[0].prompt, "do X");
|
|
43
|
+
assert(Array.isArray(res.body.sideEffects) && res.body.sideEffects.length === 1);
|
|
44
|
+
assertEquals(res.body.sideEffects[0].nodeId, "a");
|
|
45
|
+
assertEquals(res.body.sideEffects[0].kind, "agent");
|
|
46
|
+
assert(typeof res.body.sideEffects[0].description === "string" && res.body.sideEffects[0].description.length > 0);
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
test("preview-delivery-graph: is PURE — repeated previews return the identical digest", async () => {
|
|
@@ -54,6 +54,12 @@ export default defineOperation("previewDeliveryGraph", async ({ body }, app) =>
|
|
|
54
54
|
humanNodeCount: compiled.humanNodes.length,
|
|
55
55
|
sideEffectCount: compiled.sideEffects.length,
|
|
56
56
|
diagram: compiled.diagram,
|
|
57
|
+
// The FULL extracted preview detail (not just the counts): the human stop-points and the
|
|
58
|
+
// side-effecting actions the operator is being asked to approve (Decision 7). The Delivery
|
|
59
|
+
// Graphs page renders these lists so the operator sees WHERE it parks on a person and WHAT it
|
|
60
|
+
// will do before dispatching — the "preview before dispatch" principle made visible (#441).
|
|
61
|
+
humanNodes: compiled.humanNodes,
|
|
62
|
+
sideEffects: compiled.sideEffects,
|
|
57
63
|
},
|
|
58
64
|
};
|
|
59
65
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.122.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|