@nanobpm/nano-workforce 0.121.0 → 0.123.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 +6 -105
- package/app/feature.ts +30 -128
- package/app/featureReadModel.test.ts +210 -0
- 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 +36 -108
- package/app/plansReadModel.test.ts +112 -5
- package/app/service.ts +55 -81
- 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/db/migrations/073_feature_read_model.sql +80 -0
- package/db/migrations/074_plan_read_model_derive_bucket.sql +74 -0
- package/main.ts +5 -4
- package/operations/acknowledgeDone.test.ts +18 -16
- package/operations/acknowledgeDone.ts +10 -8
- package/operations/acknowledgeEpic.test.ts +76 -34
- package/operations/acknowledgeEpic.ts +15 -10
- package/package.json +1 -1
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +1 -1
- package/workers/record-plan/worker.test.ts +11 -9
- package/workers/record-plan/worker.ts +4 -8
- package/workers/record-results/worker.test.ts +7 -4
- 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/featureGateway.test.ts +0 -202
- package/app/planGateway.test.ts +0 -106
package/app/service.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { deriveDelivery, 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";
|
|
34
|
-
import {
|
|
34
|
+
import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
35
35
|
import {
|
|
36
36
|
classifyMergeability,
|
|
37
37
|
classifyPrLiveness,
|
|
@@ -52,19 +52,18 @@ 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
|
-
backfillPlanBuckets,
|
|
59
57
|
capabilityGates,
|
|
60
58
|
inboundPlanDeps,
|
|
59
|
+
type Plan,
|
|
61
60
|
planReviews,
|
|
62
61
|
plans,
|
|
63
62
|
planTaskDeps,
|
|
64
63
|
planTaskNeeds,
|
|
65
64
|
planTasks,
|
|
66
65
|
} from "./plan.ts";
|
|
67
|
-
import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
66
|
+
import { derivePromotionState, isEpicIntegrationBranch, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
68
67
|
import {
|
|
69
68
|
defaultProbeExec,
|
|
70
69
|
type ProbeExec,
|
|
@@ -1862,56 +1861,37 @@ export async function pollCapabilityGatesImpl(
|
|
|
1862
1861
|
}
|
|
1863
1862
|
}
|
|
1864
1863
|
|
|
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
1864
|
/** Sentinel status fed to `deriveDelivery` for a `plan_tasks.pr_key` whose `pull_requests` row is
|
|
1871
1865
|
* missing (DB desync). It is deliberately non-terminal and not `merged`, so a dangling PR counts as
|
|
1872
1866
|
* in-flight — never a false-positive `landed` from a silently-dropped slice. */
|
|
1873
1867
|
const MISSING_PR_STATUS = "missing";
|
|
1874
1868
|
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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,
|
|
1908
|
-
updated_at: now(),
|
|
1909
|
-
});
|
|
1910
|
-
}
|
|
1911
|
-
} catch (err) {
|
|
1912
|
-
console.error(`[poller] delivery ${plan.plan_key}: ${err}`);
|
|
1869
|
+
/** Recompute an epic's derived `delivery` signal at READ TIME (epic #412) from the SAME pure
|
|
1870
|
+
* `deriveDelivery` the `plan_delivery` VIEW encodes — by joining each slice `plan_tasks.pr_key` →
|
|
1871
|
+
* `pull_requests.status` (a dangling `pr_key` counts as in-flight, never false-`landed`). The
|
|
1872
|
+
* `plans.delivery` column was RETIRED, so the pollers that still need the signal derive it here
|
|
1873
|
+
* rather than reading a denormalised column. Non-`done` plans short-circuit to `null` (the view's
|
|
1874
|
+
* behaviour) without the per-plan task join. `statusByPrKey` is an optional once-per-pass PR-status
|
|
1875
|
+
* map (the pollers preload it to avoid an N+1); when omitted (a one-off caller like the
|
|
1876
|
+
* acknowledge-epic op) it loads only THIS plan's slice PRs on demand — never the whole table. */
|
|
1877
|
+
export async function derivePlanDelivery(
|
|
1878
|
+
data: DataLayer,
|
|
1879
|
+
plan: Plan,
|
|
1880
|
+
statusByPrKey?: Map<string, string>,
|
|
1881
|
+
): Promise<string | null> {
|
|
1882
|
+
if (plan.status !== "done") return null;
|
|
1883
|
+
const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
|
|
1884
|
+
const prStatuses: string[] = [];
|
|
1885
|
+
for (const t of tasks) {
|
|
1886
|
+
if (!t.pr_key) continue;
|
|
1887
|
+
let status = statusByPrKey?.get(t.pr_key);
|
|
1888
|
+
if (status === undefined && !statusByPrKey) {
|
|
1889
|
+
// On-demand caller: fetch just this slice's PR row rather than loading the whole table.
|
|
1890
|
+
status = (await prs(data).get(t.pr_key))?.status;
|
|
1913
1891
|
}
|
|
1892
|
+
prStatuses.push(status ?? MISSING_PR_STATUS);
|
|
1914
1893
|
}
|
|
1894
|
+
return deriveDelivery(plan.status, prStatuses).delivery;
|
|
1915
1895
|
}
|
|
1916
1896
|
|
|
1917
1897
|
/** Idempotent read-model pass (issue #292 slice S4): project each DEPENDENT epic's inter-epic gate
|
|
@@ -1926,12 +1906,30 @@ export async function pollDelivery(data: DataLayer) {
|
|
|
1926
1906
|
* a prior edge (e.g. an edge later removed) is cleared defensively so the read model never keeps a
|
|
1927
1907
|
* phantom gate. */
|
|
1928
1908
|
export async function pollWaitGate(data: DataLayer) {
|
|
1909
|
+
// Preload every plan_tasks row once per pass and group by plan_key, so deriving each plan's
|
|
1910
|
+
// fanned-out signal below is a map lookup rather than a per-plan `planTasks(data).find` (avoids
|
|
1911
|
+
// an N+1 that would double this pass's DB work alongside the per-plan `inboundPlanDeps` lookup).
|
|
1912
|
+
const wavesByPlanKey = new Map<string, number[]>();
|
|
1913
|
+
for (const t of await planTasks(data).all()) {
|
|
1914
|
+
if (t.wave == null) continue;
|
|
1915
|
+
const list = wavesByPlanKey.get(t.plan_key) ?? [];
|
|
1916
|
+
list.push(t.wave);
|
|
1917
|
+
wavesByPlanKey.set(t.plan_key, list);
|
|
1918
|
+
}
|
|
1929
1919
|
for (const plan of await plans(data).all()) {
|
|
1930
1920
|
try {
|
|
1931
1921
|
const edges = await inboundPlanDeps(data, plan.plan_key);
|
|
1922
|
+
// `plans.current_wave` was retired (epic #412); `deriveWaitGate` only consumes its
|
|
1923
|
+
// NULL-ness (proof the epic's fan-out began). Derive that at read time: an epic has fanned out
|
|
1924
|
+
// iff it has ≥1 levelized `plan_tasks` row (a wave assigned). The value's magnitude is never
|
|
1925
|
+
// surfaced here — the epic index/detail read the display `current_wave` off the
|
|
1926
|
+
// `plan_wave_label`/`plan_read_model` VIEWs — so any non-null (the frontier's min wave) is
|
|
1927
|
+
// faithful for the gate's "has this epic ever fanned out?" test.
|
|
1928
|
+
const assignedWaves = wavesByPlanKey.get(plan.plan_key) ?? [];
|
|
1929
|
+
const current_wave = assignedWaves.length > 0 ? Math.min(...assignedWaves) : null;
|
|
1932
1930
|
const { wait_gate, wait_gate_label } = deriveWaitGate(edges, {
|
|
1933
1931
|
status: plan.status,
|
|
1934
|
-
current_wave
|
|
1932
|
+
current_wave,
|
|
1935
1933
|
bound_artifacts: plan.bound_artifacts,
|
|
1936
1934
|
created_at: plan.created_at,
|
|
1937
1935
|
});
|
|
@@ -1951,8 +1949,8 @@ export async function pollWaitGate(data: DataLayer) {
|
|
|
1951
1949
|
/** Idempotent promotion pass (issue #299): open — and then track — the `epic/* → <default>`
|
|
1952
1950
|
* promotion PR for every epic that has LANDED on a custom integration branch. This is the missing
|
|
1953
1951
|
* counterpart to `ensureBaseBranch`: that creates the `epic/*` branch slices merge into; this
|
|
1954
|
-
* delivers the fully-landed branch to the default branch.
|
|
1955
|
-
*
|
|
1952
|
+
* delivers the fully-landed branch to the default branch. Derives each epic's `delivery` signal at
|
|
1953
|
+
* read time (epic #412 — the `plans.delivery` column was retired) via the same pure `deriveDelivery`.
|
|
1956
1954
|
*
|
|
1957
1955
|
* Per promotable plan (`isPromotable`: `delivery = landed` AND base is `epic/*`):
|
|
1958
1956
|
* • No promotion PR yet → open ONE `epic/* → <default>` PR (idempotent against a remote head-branch
|
|
@@ -1972,10 +1970,15 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
|
|
|
1972
1970
|
const statusByPrKey = new Map<string, string>();
|
|
1973
1971
|
for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
|
|
1974
1972
|
for (const plan of await plans(data).all()) {
|
|
1975
|
-
if (!isPromotable(plan)) continue;
|
|
1976
1973
|
const base = plan.base_branch;
|
|
1977
|
-
|
|
1974
|
+
// A non-`epic/*` base is never promotable — short-circuit before the per-plan delivery join.
|
|
1975
|
+
if (!isEpicIntegrationBranch(base)) continue;
|
|
1978
1976
|
try {
|
|
1977
|
+
// `plans.delivery` was retired (epic #412) — derive the landed signal at READ TIME from the
|
|
1978
|
+
// slice PRs (same pure `deriveDelivery` the `plan_delivery` VIEW encodes) instead of reading a
|
|
1979
|
+
// denormalised column, then apply the pure `isPromotable` predicate.
|
|
1980
|
+
const delivery = await derivePlanDelivery(data, plan, statusByPrKey);
|
|
1981
|
+
if (!isPromotable({ delivery, base_branch: base })) continue;
|
|
1979
1982
|
// Already opened → project state from the promotion PR's live status, and re-enroll it if its
|
|
1980
1983
|
// convergence row went missing (a prior submit failed, or the app/engine store desynced).
|
|
1981
1984
|
if (plan.promotion_pr) {
|
|
@@ -2398,47 +2401,18 @@ export async function pollUserTasks(
|
|
|
2398
2401
|
* The wave-merge barrier is now level-triggered and probes the engine's message-subscription state
|
|
2399
2402
|
* over the same raw-REST search surface, so it runs only when `engineRest` is supplied (as in
|
|
2400
2403
|
* production — `main.ts` always passes it). */
|
|
2401
|
-
/** One-shot guard so the feature-stage backfill (`backfillFeatureStages`) runs at most once per
|
|
2402
|
-
* process, on the first `pollOnce`. Idempotent regardless, but there is no need to re-scan every row
|
|
2403
|
-
* on every poll. */
|
|
2404
|
-
let featureStagesBackfilled = false;
|
|
2405
|
-
|
|
2406
|
-
/** One-shot guard so the epic-bucket backfill (`backfillPlanBuckets`, #298) runs at most once per
|
|
2407
|
-
* process, on the first `pollOnce` — re-projecting pre-migration-042 `plans` rows whose
|
|
2408
|
-
* `list_bucket` is still NULL. The gateway keeps every future write fresh; idempotent regardless. */
|
|
2409
|
-
let planBucketsBackfilled = false;
|
|
2410
|
-
|
|
2411
2404
|
export async function pollOnce(
|
|
2412
2405
|
data: DataLayer,
|
|
2413
2406
|
engine: EngineClient,
|
|
2414
2407
|
token: string,
|
|
2415
2408
|
engineRest?: { restAddress: string; token?: string },
|
|
2416
2409
|
) {
|
|
2417
|
-
// One-shot: re-project any pre-#254 feature_runs rows whose pipeline columns are still NULL. The
|
|
2418
|
-
// gateway keeps every future write fresh, so this only needs to run once per process and is safe to
|
|
2419
|
-
// re-run (it re-derives from each row's own stored fields).
|
|
2420
|
-
if (!featureStagesBackfilled) {
|
|
2421
|
-
// Only arm the one-shot guard AFTER a successful backfill: setting it first would swallow a
|
|
2422
|
-
// transient failure (e.g. a DB blip) and leave legacy rows unprojected forever, since every later
|
|
2423
|
-
// pass would skip. On a throw the guard stays false and the next `pollOnce` retries.
|
|
2424
|
-
await backfillFeatureStages(data);
|
|
2425
|
-
featureStagesBackfilled = true;
|
|
2426
|
-
}
|
|
2427
|
-
// One-shot: re-project any pre-#298 `plans` rows whose `list_bucket` is still NULL, so a legacy
|
|
2428
|
-
// epic buckets correctly into Active/History from the first pass. Guard armed only after success so
|
|
2429
|
-
// a transient failure retries next pass (mirrors the feature-stage backfill above).
|
|
2430
|
-
if (!planBucketsBackfilled) {
|
|
2431
|
-
await backfillPlanBuckets(data);
|
|
2432
|
-
planBucketsBackfilled = true;
|
|
2433
|
-
}
|
|
2434
2410
|
await pollReviews(data, engine, token);
|
|
2435
2411
|
await pollMerges(data, engine, token);
|
|
2436
|
-
await pollDelivery(data);
|
|
2437
2412
|
await pollWaitGate(data);
|
|
2438
2413
|
await pollPromotion(data, engine, token);
|
|
2439
2414
|
await pollFeatureDelivery(data);
|
|
2440
2415
|
await pollLineage(data);
|
|
2441
|
-
await pollMergesPerDay(data);
|
|
2442
2416
|
await pollUserTasks(data, engine, engineRest);
|
|
2443
2417
|
await pollDeliveryGraphPhase(data, engine);
|
|
2444
2418
|
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;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
-- Feature-run display projection as a derived SQL VIEW (issue #439 — the status-driven follow-up to
|
|
2
|
+
-- epic #412).
|
|
3
|
+
--
|
|
4
|
+
-- 039_feature_pipeline_stage.sql denormalised the pipeline projection
|
|
5
|
+
-- (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) onto the `feature_runs` row,
|
|
6
|
+
-- projected at WRITE TIME by the `featureRuns()` gateway (app/feature.ts) from the pure `deriveStage`
|
|
7
|
+
-- / `deriveListBucket` (app/stage.ts). That "the gateway is the sole write path" invariant held for
|
|
8
|
+
-- app-layer writes but NOT for the framework `instanceTracking` reconciler, which writes
|
|
9
|
+
-- `feature_runs.status` through the RAW datasource (`{status:"abandoned"}` on a terminated instance,
|
|
10
|
+
-- see nano.app.json → instanceTracking) — bypassing the gateway, so `status` flipped terminal while
|
|
11
|
+
-- the display columns stayed frozen at their pre-terminal values (a cancelled run wedged in Active as
|
|
12
|
+
-- a live-looking `Implementing ⚠`, its Dismiss gated shut on a null `stage_state`).
|
|
13
|
+
--
|
|
14
|
+
-- The fix is the same technique #412/#411 established (projection → VIEW, enabled by nano-ide#424):
|
|
15
|
+
-- express the derived columns as a VIEW over `status` (+ `pr_key`/`converge`/`auto_merge`/
|
|
16
|
+
-- `acknowledged_at`), removing the write-time projection entirely. There is then no stored column and
|
|
17
|
+
-- no write-path for ANY writer (the reconciler or a future one) to leave stale — the projection is a
|
|
18
|
+
-- pure function of the row's own base columns, recomputed on every read. `deriveStage` /
|
|
19
|
+
-- `deriveListBucket` remain the canonical TS implementation (used by the acknowledge operations and
|
|
20
|
+
-- as the test oracle); this VIEW's CASE expressions MIRROR them exactly, and
|
|
21
|
+
-- app/featureReadModel.test.ts pins the two in lockstep over the full status matrix.
|
|
22
|
+
--
|
|
23
|
+
-- A single plain `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no select-list subquery), so its
|
|
24
|
+
-- output columns stay parseable by the static pages↔schema contract guard (scripts/pages-contract.
|
|
25
|
+
-- test.ts) — every projected column is aliased and the derived ones are wrapped so nothing but the
|
|
26
|
+
-- real table reference reads as the top-level FROM. It projects the `feature_runs` columns the Feature
|
|
27
|
+
-- page references PLUS the five derived columns (same shape as 061's plan_read_model): the base
|
|
28
|
+
-- `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` columns are deliberately NOT
|
|
29
|
+
-- selected — the derived CASE expressions take those names — so a stale stored value can never surface.
|
|
30
|
+
--
|
|
31
|
+
-- Forward-only, additive (a new read model, no schema change to feature_runs). The runner wraps each
|
|
32
|
+
-- file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after the current
|
|
33
|
+
-- highest prefix (072 — the #412 wave-1 contract cleanup landed a disjoint 070-079 block).
|
|
34
|
+
|
|
35
|
+
CREATE VIEW feature_read_model AS
|
|
36
|
+
SELECT
|
|
37
|
+
fr.feature_key AS feature_key,
|
|
38
|
+
fr.repo AS repo,
|
|
39
|
+
fr.issue_number AS issue_number,
|
|
40
|
+
fr.issue_url AS issue_url,
|
|
41
|
+
fr.title AS title,
|
|
42
|
+
fr.base_branch AS base_branch,
|
|
43
|
+
fr.status AS status,
|
|
44
|
+
fr.process_key AS process_key,
|
|
45
|
+
fr.pr_key AS pr_key,
|
|
46
|
+
fr.converge AS converge,
|
|
47
|
+
fr.auto_merge AS auto_merge,
|
|
48
|
+
fr.outcome AS outcome,
|
|
49
|
+
fr.delivery_label AS delivery_label,
|
|
50
|
+
fr.acknowledged_at AS acknowledged_at,
|
|
51
|
+
fr.created_at AS created_at,
|
|
52
|
+
fr.updated_at AS updated_at,
|
|
53
|
+
(CASE
|
|
54
|
+
WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') THEN 'Done'
|
|
55
|
+
WHEN fr.status = 'converging' THEN 'Converging'
|
|
56
|
+
WHEN (fr.pr_key IS NOT NULL AND fr.pr_key <> '') OR fr.status = 'opened' THEN 'PR open'
|
|
57
|
+
WHEN fr.status IN ('running', 'escalated', 'awaiting_operator') THEN 'Implementing'
|
|
58
|
+
ELSE 'Requested'
|
|
59
|
+
END) AS stage,
|
|
60
|
+
(CASE
|
|
61
|
+
WHEN fr.status IN ('merged', 'converged') THEN 'ok'
|
|
62
|
+
WHEN fr.status = 'blocked' THEN 'blocked'
|
|
63
|
+
WHEN fr.status IN ('failed', 'skipped', 'abandoned') THEN 'failed'
|
|
64
|
+
ELSE NULL
|
|
65
|
+
END) AS stage_state,
|
|
66
|
+
(CASE
|
|
67
|
+
WHEN NOT (fr.converge IS NOT NULL AND fr.converge <> 0) THEN 'Converging Merging'
|
|
68
|
+
WHEN NOT (fr.auto_merge IS NOT NULL AND fr.auto_merge <> 0) THEN 'Merging'
|
|
69
|
+
ELSE ''
|
|
70
|
+
END) AS stage_skipped,
|
|
71
|
+
(CASE
|
|
72
|
+
WHEN fr.status = 'awaiting_operator' THEN 'blocked'
|
|
73
|
+
WHEN fr.status = 'escalated' THEN '⚠'
|
|
74
|
+
ELSE NULL
|
|
75
|
+
END) AS attention,
|
|
76
|
+
(CASE
|
|
77
|
+
WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') AND fr.acknowledged_at IS NOT NULL THEN 'history'
|
|
78
|
+
ELSE 'active'
|
|
79
|
+
END) AS list_bucket
|
|
80
|
+
FROM feature_runs fr;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
-- Derive the epic Active/History bucket in the read-model VIEW instead of at write time (issue #439 —
|
|
2
|
+
-- the status-driven follow-up to epic #412).
|
|
3
|
+
--
|
|
4
|
+
-- 044_plan_list_bucket.sql denormalised `plans.list_bucket` / `plans.ack_open` onto the row, projected
|
|
5
|
+
-- at WRITE TIME by the `plans()` gateway (app/plan.ts) from the pure `deriveEpicBucket` /
|
|
6
|
+
-- `epicIsAcknowledgeable` (app/delivery.ts), with a read-time `pollPlanBucket` pass (app/service.ts)
|
|
7
|
+
-- supplying the delivery-aware correction the delivery-free gateway could not. As with feature_runs
|
|
8
|
+
-- (073), that write path is bypassed by the framework `instanceTracking` reconciler, which writes
|
|
9
|
+
-- `plans.status` through the RAW datasource (`{status:"abandoned"}` on a terminated instance) —
|
|
10
|
+
-- leaving `list_bucket`/`ack_open` frozen at their pre-terminal values, so a cancelled epic drifts the
|
|
11
|
+
-- same way a cancelled feature run does.
|
|
12
|
+
--
|
|
13
|
+
-- 061_plan_delivery_rollup.sql already made `plan_read_model` the composite VIEW the operator pages
|
|
14
|
+
-- bind, but it still READ `pl.list_bucket` / `pl.ack_open` straight off the denormalised columns. This
|
|
15
|
+
-- migration redefines `plan_read_model` (same name, so no page repoint is needed) to DERIVE both from
|
|
16
|
+
-- the base inputs — `status`, `acknowledged_at`, and the already-derived `plan_delivery.delivery`
|
|
17
|
+
-- signal — removing the last read of the stored columns. The bucket is now a pure function with no
|
|
18
|
+
-- write-path: there is nothing for the reconciler (or `pollPlanBucket`, which this retires) to leave
|
|
19
|
+
-- stale. The CASE expressions MIRROR `deriveEpicBucket` / `epicIsAcknowledgeable` exactly, cross-checked
|
|
20
|
+
-- against those pure functions in app/plansReadModel.test.ts.
|
|
21
|
+
--
|
|
22
|
+
-- Deriving from the REAL delivery signal is strictly MORE correct than the old write-time projection,
|
|
23
|
+
-- which had to assume `delivery = null` (it could not see the read-time signal) and relied on
|
|
24
|
+
-- `pollPlanBucket` to clear `ack_open` while a `done` epic was still converging. The view sees the live
|
|
25
|
+
-- `plan_delivery.delivery`, so a still-converging epic never offers Dismiss (`ack_open = 0`) without a
|
|
26
|
+
-- poller pass.
|
|
27
|
+
--
|
|
28
|
+
-- A merged view is not editable in place (that would edit a shipped migration), so this DROPs and
|
|
29
|
+
-- re-CREATEs it. `plan_read_model` is a leaf — no other view builds on it — so the DROP is safe. Its
|
|
30
|
+
-- output column set is UNCHANGED (list_bucket/ack_open are still projected, only their derivation
|
|
31
|
+
-- changed), so the pages↔schema contract guard and every page binding stay valid.
|
|
32
|
+
--
|
|
33
|
+
-- Forward-only. NO BEGIN/COMMIT — the runner wraps each file in its own transaction. Numbered after
|
|
34
|
+
-- 073.
|
|
35
|
+
|
|
36
|
+
DROP VIEW plan_read_model;
|
|
37
|
+
|
|
38
|
+
CREATE VIEW plan_read_model AS
|
|
39
|
+
SELECT
|
|
40
|
+
pl.plan_key AS plan_key,
|
|
41
|
+
pl.repo AS repo,
|
|
42
|
+
pl.issue_number AS issue_number,
|
|
43
|
+
pl.issue_url AS issue_url,
|
|
44
|
+
pl.title AS title,
|
|
45
|
+
pl.status AS status,
|
|
46
|
+
pl.task_count AS task_count,
|
|
47
|
+
pl.process_key AS process_key,
|
|
48
|
+
pl.outcome AS outcome,
|
|
49
|
+
pl.updated_at AS updated_at,
|
|
50
|
+
pl.epic_phase AS epic_phase,
|
|
51
|
+
pl.base_branch AS base_branch,
|
|
52
|
+
pl.wait_gate_label AS wait_gate_label,
|
|
53
|
+
pl.bound_artifacts AS bound_artifacts,
|
|
54
|
+
pl.promotion_pr AS promotion_pr,
|
|
55
|
+
pl.promotion_state AS promotion_state,
|
|
56
|
+
(CASE
|
|
57
|
+
WHEN pl.status IN ('planning', 'dispatched') THEN 'active'
|
|
58
|
+
WHEN pl.status = 'done' AND d.delivery = 'converging' THEN 'active'
|
|
59
|
+
WHEN pl.status = 'done' AND pl.acknowledged_at IS NULL THEN 'active'
|
|
60
|
+
WHEN pl.status = 'done' THEN 'history'
|
|
61
|
+
ELSE 'history'
|
|
62
|
+
END) AS list_bucket,
|
|
63
|
+
(CASE
|
|
64
|
+
WHEN pl.status = 'done' AND d.delivery IS NOT 'converging' AND pl.acknowledged_at IS NULL THEN 1
|
|
65
|
+
ELSE 0
|
|
66
|
+
END) AS ack_open,
|
|
67
|
+
wl.wave_count AS wave_count,
|
|
68
|
+
wl.current_wave AS current_wave,
|
|
69
|
+
wl.wave_label AS wave_label,
|
|
70
|
+
d.delivery AS delivery,
|
|
71
|
+
d.delivery_label AS delivery_label
|
|
72
|
+
FROM plans pl
|
|
73
|
+
LEFT JOIN plan_wave_label wl ON wl.plan_key = pl.plan_key
|
|
74
|
+
LEFT JOIN plan_delivery d ON d.plan_key = pl.plan_key;
|
package/main.ts
CHANGED
|
@@ -109,10 +109,11 @@ async function pollLoop(): Promise<void> {
|
|
|
109
109
|
}
|
|
110
110
|
if (!shuttingDown) pollTimer = setTimeout(() => void pollLoop(), POLL_MS);
|
|
111
111
|
}
|
|
112
|
-
// Run the first pass immediately at boot (not after POLL_MS) so the
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
112
|
+
// Run the first pass immediately at boot (not after POLL_MS) so the read-model pollers (delivery,
|
|
113
|
+
// wait-gate, promotion, lineage) reconcile before the UI is relied upon rather than after up to
|
|
114
|
+
// POLL_MS. The Feature Runs grid/tabs now filter the `feature_read_model` VIEW's derived `stage`/
|
|
115
|
+
// `list_bucket` (issue #439), computed from each row's own `status`, so no boot-time backfill of a
|
|
116
|
+
// stored projection is required.
|
|
116
117
|
if (app.data) void pollLoop();
|
|
117
118
|
|
|
118
119
|
async function drainAndExit(): Promise<void> {
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// Tests for the POST /app/api/actions/acknowledge-done operation `acknowledgeDone` (issue #254 §5).
|
|
2
|
-
// The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at
|
|
3
|
-
//
|
|
4
|
-
// History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is
|
|
5
|
-
//
|
|
2
|
+
// The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at`, which
|
|
3
|
+
// the `feature_read_model` VIEW (073, issue #439) derives into `list_bucket` = 'history', dropping the
|
|
4
|
+
// run from Active into History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is
|
|
5
|
+
// not parked). Since `list_bucket` is now a VIEW over `status`/`acknowledged_at` (no stored column),
|
|
6
|
+
// these tests assert the operation's real write — the `acknowledged_at` stamp — and cross-check the
|
|
7
|
+
// resulting bucket through the pure `deriveListBucket` oracle the VIEW mirrors.
|
|
6
8
|
import { test } from "node:test";
|
|
7
9
|
import { assertEquals } from "#test-assert";
|
|
8
10
|
import type { AppApi } from "@nanobpm/urban";
|
|
9
|
-
import {
|
|
11
|
+
import { deriveListBucket } from "../app/stage.ts";
|
|
10
12
|
import { noopLog } from "../test/log.ts";
|
|
11
13
|
import handler from "./acknowledgeDone.ts";
|
|
12
14
|
|
|
13
|
-
// An in-memory data layer wired through the
|
|
14
|
-
//
|
|
15
|
+
// An in-memory data layer wired through the `featureRuns` gateway (now a plain record table), so the
|
|
16
|
+
// test exercises the operation's write path exactly as production does.
|
|
15
17
|
function memApp(seed: any[]): { app: AppApi; rows: any[] } {
|
|
16
18
|
const stores: Record<string, any[]> = { feature_runs: seed };
|
|
17
19
|
function tbl(name: string, pk = "id") {
|
|
@@ -49,30 +51,30 @@ async function call(app: AppApi, body: unknown) {
|
|
|
49
51
|
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
test("acknowledge-done: stamps acknowledged_at and
|
|
54
|
+
test("acknowledge-done: stamps acknowledged_at and (via the VIEW) buckets a terminal row into History", async () => {
|
|
53
55
|
const { app, rows } = memApp([{ feature_key: "o/r#1", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
56
|
+
// Before dismissal a terminal-but-unacknowledged run reads as Active through the VIEW.
|
|
57
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
|
|
57
58
|
|
|
58
59
|
const res = await call(app, { feature_key: "o/r#1" });
|
|
59
60
|
|
|
60
61
|
assertEquals(res.status, 200);
|
|
61
62
|
assertEquals(res.body.ok, true);
|
|
62
63
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
63
|
-
|
|
64
|
+
// The op's only write is the stamp; the VIEW derives 'history' from (terminal status + acknowledged).
|
|
65
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
64
66
|
});
|
|
65
67
|
|
|
66
68
|
test("acknowledge-done: idempotent-safe — re-acknowledging keeps the row in History", async () => {
|
|
67
69
|
const { app, rows } = memApp([{ feature_key: "o/r#2", status: "failed", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
68
70
|
const first = await call(app, { feature_key: "o/r#2" });
|
|
69
71
|
assertEquals(first.status, 200);
|
|
70
|
-
assertEquals(rows[0].
|
|
72
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
71
73
|
const firstStamp = rows[0].acknowledged_at;
|
|
72
74
|
// Re-acknowledge — still 200, still history.
|
|
73
75
|
const second = await call(app, { feature_key: "o/r#2" });
|
|
74
76
|
assertEquals(second.status, 200);
|
|
75
|
-
assertEquals(rows[0].
|
|
77
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
76
78
|
assertEquals(typeof firstStamp, "string");
|
|
77
79
|
});
|
|
78
80
|
|
|
@@ -92,12 +94,12 @@ test("acknowledge-done: no such feature run → 404", async () => {
|
|
|
92
94
|
|
|
93
95
|
test("acknowledge-done: a non-terminal run → 409, no acknowledged_at stamped", async () => {
|
|
94
96
|
const { app, rows } = memApp([{ feature_key: "o/r#live", status: "running", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
95
|
-
await featureRuns(app.data).update("o/r#live", { status: "running" });
|
|
96
97
|
const res = await call(app, { feature_key: "o/r#live" });
|
|
97
98
|
assertEquals(res.status, 409);
|
|
98
99
|
assertEquals(res.body.ok, false);
|
|
99
100
|
assertEquals(rows[0].acknowledged_at, null);
|
|
100
|
-
|
|
101
|
+
// A live run stays Active through the VIEW (no stamp → not History).
|
|
102
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
test("acknowledge-done: a converging (redispatch-terminal but live) run → 409", async () => {
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the primary
|
|
4
4
|
// Active list into History. It is the DONE twin of `acknowledgeBlocked` — but a terminal run is NOT
|
|
5
5
|
// parked at a user task, so this op does NOT complete a user task and touches no engine/ledger: it
|
|
6
|
-
// simply stamps `acknowledged_at` on the row via the feature_runs
|
|
6
|
+
// simply stamps `acknowledged_at` on the row via the plain `feature_runs` record table (the projecting
|
|
7
|
+
// gateway this PR retired). It rejects (409) a run that
|
|
7
8
|
// is not yet truly terminal, so it can never pre-seed the tick-off on a still-live run.
|
|
8
9
|
//
|
|
9
|
-
// The
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// The `list_bucket` partition is DERIVED by the `feature_read_model` VIEW (073, issue #439) from
|
|
11
|
+
// `status` + `acknowledged_at` — a terminal row with `acknowledged_at` set reads as 'history' — so
|
|
12
|
+
// this op NEVER writes `list_bucket` (or any projection): stamping `acknowledged_at` is the whole
|
|
13
|
+
// contract. Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps the
|
|
14
|
+
// timestamp and keeps the row in History.
|
|
13
15
|
|
|
14
16
|
import { featureRuns } from "../app/feature.ts";
|
|
15
17
|
import { STAGE_DONE_STATUSES } from "../app/stage.ts";
|
|
@@ -42,9 +44,9 @@ export default defineOperation("acknowledgeDone", async ({ body }, app) => {
|
|
|
42
44
|
return { status: 409, body: { ok: false, error: "feature run is not terminal" } };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
// Stamp the dismissal.
|
|
46
|
-
// terminal
|
|
47
|
-
// History.
|
|
47
|
+
// Stamp the dismissal. `list_bucket` is derived by the `feature_read_model` VIEW (→ 'history' for a
|
|
48
|
+
// terminal, acknowledged row), so we never hand-set it here. Idempotent: re-acknowledging re-stamps
|
|
49
|
+
// and stays in History.
|
|
48
50
|
const now = new Date().toISOString();
|
|
49
51
|
await runs.update(featureKey, { acknowledged_at: now, updated_at: now });
|
|
50
52
|
|