@bongos/core 1.19.595 → 1.19.597
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/.bongos-core.json +37 -17
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_234_goals_is_maintenance.sql +57 -0
- package/migrations/core_235_one_maintenance_goal_per_version.sql +41 -0
- package/modules/lifecycle/db-goals.js +152 -17
- package/modules/lifecycle/db-versions.js +12 -12
- package/modules/lifecycle/db.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_seed.mjs +0 -10
- package/tests/maintenance_carry_forward.mjs +122 -0
- package/tests/maintenance_goal.mjs +8 -2
- package/tests/maintenance_goal_flag.mjs +223 -0
- package/tests/version_close_route.mjs +10 -3
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.597",
|
|
6
|
+
"core_contract": "1.19.597",
|
|
7
|
+
"source_commit": "1015d778d266d3357eaffb989262715110662ad1",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-08T04:16:25.146Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 457,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2073,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2554,
|
|
20
|
+
"tree_sha256": "47e446b7dc9ebe46479e9138f5c45722dd3aa0c70e847abc587415af364f4d0b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2727,7 +2727,7 @@
|
|
|
2727
2727
|
{
|
|
2728
2728
|
"path": "docs/module-api-changelog.md",
|
|
2729
2729
|
"mode": "0000644",
|
|
2730
|
-
"sha256": "
|
|
2730
|
+
"sha256": "adfc4f65fbe9fc3265ec5599eb55dbe4c25063ae382006530df8c96ed930fc67"
|
|
2731
2731
|
},
|
|
2732
2732
|
{
|
|
2733
2733
|
"path": "docs/modules-contract.md",
|
|
@@ -3734,6 +3734,16 @@
|
|
|
3734
3734
|
"mode": "0000644",
|
|
3735
3735
|
"sha256": "ca03e583bc418f600c1b1ba464bcaf4e380916033ec06cd68990cf153184d883"
|
|
3736
3736
|
},
|
|
3737
|
+
{
|
|
3738
|
+
"path": "migrations/core_234_goals_is_maintenance.sql",
|
|
3739
|
+
"mode": "0000644",
|
|
3740
|
+
"sha256": "92964dc3578fb2f4b1b7547867d0f53b2998c30bdb39271336e9b9aa348a1b37"
|
|
3741
|
+
},
|
|
3742
|
+
{
|
|
3743
|
+
"path": "migrations/core_235_one_maintenance_goal_per_version.sql",
|
|
3744
|
+
"mode": "0000644",
|
|
3745
|
+
"sha256": "c10c4bbbead4a3bafde336f3433a1432818880751f7a21436a6162f0dbde1abf"
|
|
3746
|
+
},
|
|
3737
3747
|
{
|
|
3738
3748
|
"path": "modules/agents/lib/validate.js",
|
|
3739
3749
|
"mode": "0000644",
|
|
@@ -5522,7 +5532,7 @@
|
|
|
5522
5532
|
{
|
|
5523
5533
|
"path": "modules/lifecycle/db-goals.js",
|
|
5524
5534
|
"mode": "0000644",
|
|
5525
|
-
"sha256": "
|
|
5535
|
+
"sha256": "c3e06daaf78911c9dc44f72aa7a104f269624c92f343cd808b2962076a641a23"
|
|
5526
5536
|
},
|
|
5527
5537
|
{
|
|
5528
5538
|
"path": "modules/lifecycle/db-grade.js",
|
|
@@ -5557,12 +5567,12 @@
|
|
|
5557
5567
|
{
|
|
5558
5568
|
"path": "modules/lifecycle/db-versions.js",
|
|
5559
5569
|
"mode": "0000644",
|
|
5560
|
-
"sha256": "
|
|
5570
|
+
"sha256": "9d909149be4152335f6d259a32621c2e3917e98c2595ac1c8ff0d2202891dbcb"
|
|
5561
5571
|
},
|
|
5562
5572
|
{
|
|
5563
5573
|
"path": "modules/lifecycle/db.js",
|
|
5564
5574
|
"mode": "0000644",
|
|
5565
|
-
"sha256": "
|
|
5575
|
+
"sha256": "14c05469abcf349c9173a80059e7fa0dd880b1b5a0b25680ba0d9e524e46d5d9"
|
|
5566
5576
|
},
|
|
5567
5577
|
{
|
|
5568
5578
|
"path": "modules/lifecycle/dead-deps.js",
|
|
@@ -7612,12 +7622,12 @@
|
|
|
7612
7622
|
{
|
|
7613
7623
|
"path": "package-lock.json",
|
|
7614
7624
|
"mode": "0000644",
|
|
7615
|
-
"sha256": "
|
|
7625
|
+
"sha256": "0318db53e76bb62b788fce5bf7dfd540e606e3fdcd1ab1e205732deba3a4bf03"
|
|
7616
7626
|
},
|
|
7617
7627
|
{
|
|
7618
7628
|
"path": "package.json",
|
|
7619
7629
|
"mode": "0000644",
|
|
7620
|
-
"sha256": "
|
|
7630
|
+
"sha256": "8d5d6869dab12ea697b5e0fae60883aabd464ae4568a47bc997a21cb8933cb59"
|
|
7621
7631
|
},
|
|
7622
7632
|
{
|
|
7623
7633
|
"path": "public-docs/index.html",
|
|
@@ -9332,7 +9342,7 @@
|
|
|
9332
9342
|
{
|
|
9333
9343
|
"path": "src/module-api.js",
|
|
9334
9344
|
"mode": "0000644",
|
|
9335
|
-
"sha256": "
|
|
9345
|
+
"sha256": "2cb11336e7f6bde05dafd46c2ea3b94d9e5c7ae1f9be772a49b793e17baf7f0d"
|
|
9336
9346
|
},
|
|
9337
9347
|
{
|
|
9338
9348
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10722,7 +10732,7 @@
|
|
|
10722
10732
|
{
|
|
10723
10733
|
"path": "tests/government_seed.mjs",
|
|
10724
10734
|
"mode": "0000644",
|
|
10725
|
-
"sha256": "
|
|
10735
|
+
"sha256": "6fa11446c88018ab6e62a72756f96f176bfaf6cd6099dc231746645ba0f538d2"
|
|
10726
10736
|
},
|
|
10727
10737
|
{
|
|
10728
10738
|
"path": "tests/government_session_scope.mjs",
|
|
@@ -11279,10 +11289,20 @@
|
|
|
11279
11289
|
"mode": "0000644",
|
|
11280
11290
|
"sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
|
|
11281
11291
|
},
|
|
11292
|
+
{
|
|
11293
|
+
"path": "tests/maintenance_carry_forward.mjs",
|
|
11294
|
+
"mode": "0000644",
|
|
11295
|
+
"sha256": "4ac9164b84ad7bf8e9c1b45e7cb5e157762c992ddde16041410a528a534e60f5"
|
|
11296
|
+
},
|
|
11282
11297
|
{
|
|
11283
11298
|
"path": "tests/maintenance_goal.mjs",
|
|
11284
11299
|
"mode": "0000644",
|
|
11285
|
-
"sha256": "
|
|
11300
|
+
"sha256": "aa0d19a1d9c2f5ae9e89a77ef8e5c485f0f759e6a36dab988c8c49f0f2b59d27"
|
|
11301
|
+
},
|
|
11302
|
+
{
|
|
11303
|
+
"path": "tests/maintenance_goal_flag.mjs",
|
|
11304
|
+
"mode": "0000644",
|
|
11305
|
+
"sha256": "37356bcd78ff92537a5512b60f2274d573b6c90bb30f5deca006a14d4841fc95"
|
|
11286
11306
|
},
|
|
11287
11307
|
{
|
|
11288
11308
|
"path": "tests/manage_manifest_shared_read.mjs",
|
|
@@ -12682,7 +12702,7 @@
|
|
|
12682
12702
|
{
|
|
12683
12703
|
"path": "tests/version_close_route.mjs",
|
|
12684
12704
|
"mode": "0000644",
|
|
12685
|
-
"sha256": "
|
|
12705
|
+
"sha256": "9a5b8f753c4b898b447b257d64c0034a83fe257c23208ce714e2e469fcaf735d"
|
|
12686
12706
|
},
|
|
12687
12707
|
{
|
|
12688
12708
|
"path": "tests/version_literals.mjs",
|
|
@@ -1639,5 +1639,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1639
1639
|
landed since 1.19.593 with no explicit bump. run 34184562228. (task 1002620)
|
|
1640
1640
|
1.19.595 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1641
1641
|
landed since 1.19.594 with no explicit bump. run 34185042811. (task 1002620)
|
|
1642
|
+
1.19.596 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1643
|
+
landed since 1.19.595 with no explicit bump. run 34185477798. (task 1002620)
|
|
1644
|
+
1.19.597 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1645
|
+
landed since 1.19.596 with no explicit bump. run 34186368179. (task 1002620)
|
|
1642
1646
|
---------------------------------------------------------------------------
|
|
1643
1647
|
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- core_234_goals_is_maintenance.sql — say which goal is the version's maintenance
|
|
2
|
+
-- goal in a COLUMN, not in its title (BV1.R18, task 1003605, goal 1000086,
|
|
3
|
+
-- ADR 0263 §7).
|
|
4
|
+
--
|
|
5
|
+
-- WHY THIS IS LOAD-BEARING AND NOT COSMETIC. Until now the maintenance goal was
|
|
6
|
+
-- matched by title — `ensureMaintenanceGoal` (task 1003691) looks up
|
|
7
|
+
-- `"<version> — maintenance"` and its own comment flagged this task as the debt.
|
|
8
|
+
-- For ROUTING a bug that is a provenance wrinkle: the worst case is a
|
|
9
|
+
-- hand-titled goal receiving a defect. For the CLOSE COUNT it is far worse.
|
|
10
|
+
-- `maintenanceGoalExemptSql` decides which goals hold a VERSION open, so with a
|
|
11
|
+
-- title match any Metic+ able to create a goal could name it
|
|
12
|
+
-- `"BONGOS-V1 — maintenance"` and silently exempt it from the gate that decides
|
|
13
|
+
-- when a version may close — widening scope by choosing a string. A boolean
|
|
14
|
+
-- column cannot be spelled into existence.
|
|
15
|
+
--
|
|
16
|
+
-- THE BACKFILL IS BY TITLE, ONCE, and that is the only place the title match is
|
|
17
|
+
-- allowed to survive: the rows that already exist were created by the title
|
|
18
|
+
-- convention, so the convention is how they are identified — exactly once, here,
|
|
19
|
+
-- with the schema then carrying the fact forward. `ensureMaintenanceGoal` stops
|
|
20
|
+
-- reading titles in the same task.
|
|
21
|
+
--
|
|
22
|
+
-- NOT UNIQUE PER VERSION, deliberately. A partial unique index on
|
|
23
|
+
-- (version_id) WHERE is_maintenance would be the tidy thing and would FAIL on any
|
|
24
|
+
-- database that already has two title-matching goals for one version — which is
|
|
25
|
+
-- possible, because `goals` has no unique constraint on (version_id, title) and
|
|
26
|
+
-- the pre-lock window in migration 160's era could produce one. The find-or-create
|
|
27
|
+
-- is already serialised by pg_advisory_xact_lock, and R18 makes it read the flag
|
|
28
|
+
-- with LIMIT 1, so a duplicate is survivable rather than fatal. Adding the
|
|
29
|
+
-- constraint is a follow-up that must first prove the data allows it — the same
|
|
30
|
+
-- lesson core_233 recorded about the building-version index.
|
|
31
|
+
--
|
|
32
|
+
-- CORE, not modules/lifecycle/migrations/: `goals` is a CORE table (migration
|
|
33
|
+
-- 160), and a module migration may only touch its own <key>_-prefixed objects —
|
|
34
|
+
-- fitness.js checkModuleMigrationsAdditive hard-fails an `ALTER TABLE goals` from
|
|
35
|
+
-- a module (ADR 0083 Decision #5).
|
|
36
|
+
--
|
|
37
|
+
-- Forward-safe: one additive column with a NOT NULL default, so old code on the
|
|
38
|
+
-- new schema neither sees nor needs it (every reader SELECTs an explicit column
|
|
39
|
+
-- list). A rollback leaves the column populated and unread.
|
|
40
|
+
|
|
41
|
+
ALTER TABLE goals
|
|
42
|
+
ADD COLUMN IF NOT EXISTS is_maintenance boolean NOT NULL DEFAULT false;
|
|
43
|
+
|
|
44
|
+
-- Backfill: every goal whose title is the maintenance convention for its own
|
|
45
|
+
-- version. Anchored on the version id rather than a bare LIKE '%maintenance%',
|
|
46
|
+
-- so a goal legitimately titled "Maintenance of the art pipeline" is untouched.
|
|
47
|
+
UPDATE goals
|
|
48
|
+
SET is_maintenance = true
|
|
49
|
+
WHERE is_maintenance = false
|
|
50
|
+
AND title = version_id || ' — maintenance';
|
|
51
|
+
|
|
52
|
+
-- Finding the version's maintenance goal is now a flag read on a hot-ish path
|
|
53
|
+
-- (every homeless bug, every close count), so give it an index. Partial: the
|
|
54
|
+
-- overwhelming majority of goals are not maintenance goals.
|
|
55
|
+
CREATE INDEX IF NOT EXISTS goals_maintenance_by_version_idx
|
|
56
|
+
ON goals (version_id)
|
|
57
|
+
WHERE is_maintenance;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- core_235_one_maintenance_goal_per_version.sql — make "one maintenance goal per
|
|
2
|
+
-- version" a CONSTRAINT rather than a convention (task 1003708, BV1.R18b,
|
|
3
|
+
-- goal 1000086, ADR 0263 §7).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT core_234 LEFT OPEN. That migration added `goals.is_maintenance` and gave it
|
|
6
|
+
-- a partial index for lookup speed, which is correct and is why this one is small.
|
|
7
|
+
-- But the index is non-unique, so two goals on one version can both carry the flag
|
|
8
|
+
-- — and `ensureMaintenanceGoal` (db-goals.js) is a find-or-create whose read is
|
|
9
|
+
-- `WHERE version_id = $1 AND is_maintenance LIMIT 1`. With two flagged rows that
|
|
10
|
+
-- `LIMIT 1` returns whichever the planner picks, and every homeless bug files into
|
|
11
|
+
-- an arbitrary one of them, permanently.
|
|
12
|
+
--
|
|
13
|
+
-- That is EXACTLY the failure `pg_advisory_xact_lock` was taken for in task
|
|
14
|
+
-- 1003691 — two bugs reported in the same second both reading "absent" and both
|
|
15
|
+
-- inserting — arriving through a different door. The lock serialises the RACE; it
|
|
16
|
+
-- cannot stop a second maintenance goal created any other way (a hand-set flag, a
|
|
17
|
+
-- backfill on a version whose title convention drifted, a future feature). Moving
|
|
18
|
+
-- from title to flag was the opportunity to make it a real constraint, because
|
|
19
|
+
-- `goals` never had a unique constraint on (version_id, title) and the lock had
|
|
20
|
+
-- been covering for that absence all along.
|
|
21
|
+
--
|
|
22
|
+
-- THE UPGRADE MAY FAIL, AND THAT IS THE POINT. If any version already has two
|
|
23
|
+
-- flagged goals, this index cannot be created and the migration stops — loudly,
|
|
24
|
+
-- before an ambiguous find-or-create becomes load-bearing. core_234's backfill
|
|
25
|
+
-- keys on an exact title, so a duplicate is unlikely; if one exists it is a real
|
|
26
|
+
-- data defect and the right response is to fix the rows, not to weaken the index.
|
|
27
|
+
-- (Verified against live before writing this: no version has more than one.)
|
|
28
|
+
--
|
|
29
|
+
-- Replaces core_234's index rather than adding a second on the same expression:
|
|
30
|
+
-- a UNIQUE index serves every read the plain one did, so keeping both would be
|
|
31
|
+
-- two indexes doing one job.
|
|
32
|
+
|
|
33
|
+
BEGIN;
|
|
34
|
+
|
|
35
|
+
CREATE UNIQUE INDEX IF NOT EXISTS goals_one_maintenance_per_version_idx
|
|
36
|
+
ON goals (version_id)
|
|
37
|
+
WHERE is_maintenance;
|
|
38
|
+
|
|
39
|
+
DROP INDEX IF EXISTS goals_maintenance_by_version_idx;
|
|
40
|
+
|
|
41
|
+
COMMIT;
|
|
@@ -34,8 +34,13 @@ const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
|
34
34
|
|
|
35
35
|
// Shared column list so every goal read/return shapes a row identically.
|
|
36
36
|
|
|
37
|
+
// `is_maintenance` (BV1.R18, task 1003605, migration core_234) rides the shared
|
|
38
|
+
// list because it is a fact ABOUT the goal that several surfaces need to read —
|
|
39
|
+
// the close count's exemption, and the hall, which should not render the one goal
|
|
40
|
+
// nobody chose as though a person had scoped it. Additive: every consumer selects
|
|
41
|
+
// this list, so nothing had to be found and changed.
|
|
37
42
|
const GOAL_COLS =
|
|
38
|
-
'id, version_id, title, subtitle, description, scope_modules, status, visibility, accepting_requests, created_by, succeeded_by_goal_id, sort_order, category_id, created_at, updated_at';
|
|
43
|
+
'id, version_id, title, subtitle, description, scope_modules, status, visibility, accepting_requests, created_by, succeeded_by_goal_id, sort_order, category_id, is_maintenance, created_at, updated_at';
|
|
39
44
|
|
|
40
45
|
// Shared column list for a goal_membership_requests row (task 1951 / ADR 0112) —
|
|
41
46
|
// the invite / join-request queue behind private goals. Every helper below shapes
|
|
@@ -128,19 +133,26 @@ async function ensureMaintenanceGoal(versionId, deps = {}) {
|
|
|
128
133
|
|
|
129
134
|
await exec.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [title]);
|
|
130
135
|
|
|
131
|
-
// MATCHED BY
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
136
|
+
// MATCHED BY FLAG since BV1.R18 (task 1003605, migration core_234). This used
|
|
137
|
+
// to match by TITLE, and its own comment called that "a provenance wrinkle
|
|
138
|
+
// rather than a way in" — true for routing, where the worst case was a
|
|
139
|
+
// hand-titled goal receiving a bug. It stopped being true once the same
|
|
140
|
+
// identification decided which goals hold a VERSION open
|
|
141
|
+
// (`maintenanceGoalExemptSql`): a Metic+ could then exempt a goal from the
|
|
142
|
+
// close gate by naming it. `goals.is_maintenance` cannot be spelled.
|
|
138
143
|
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
144
|
+
// LIMIT 1 with no unique constraint behind it is deliberate — see core_234 on
|
|
145
|
+
// why the partial unique index is a follow-up rather than part of this
|
|
146
|
+
// migration. The advisory lock above is what keeps this find-or-create from
|
|
147
|
+
// racing itself; the LIMIT is what keeps a pre-existing duplicate survivable
|
|
148
|
+
// instead of fatal. Ordered by id so the answer is at least stable.
|
|
149
|
+
//
|
|
150
|
+
// `goals_maintenance_by_version_idx` (core_234) backs this read.
|
|
141
151
|
const { rows: found } = await exec.query(
|
|
142
|
-
`SELECT ${GOAL_COLS} FROM goals
|
|
143
|
-
|
|
152
|
+
`SELECT ${GOAL_COLS} FROM goals
|
|
153
|
+
WHERE version_id = $1 AND is_maintenance
|
|
154
|
+
ORDER BY id LIMIT 1`,
|
|
155
|
+
[versionId]
|
|
144
156
|
);
|
|
145
157
|
if (found[0]) return found[0];
|
|
146
158
|
|
|
@@ -158,21 +170,142 @@ async function ensureMaintenanceGoal(versionId, deps = {}) {
|
|
|
158
170
|
// split (sub-Metic lands 'backlog', never 'ready') and createTask's own derived
|
|
159
171
|
// floor. Do not "fix" this by inventing a scope list — narrow the GRANT instead,
|
|
160
172
|
// and write down why.
|
|
173
|
+
// is_maintenance = true is what makes this goal exempt from the version-close
|
|
174
|
+
// count (ADR 0263 §7) — set HERE, in the same statement that creates it, so a
|
|
175
|
+
// maintenance goal cannot exist unflagged for even one transaction. The title
|
|
176
|
+
// is still written to the convention, because it is what a person reads on the
|
|
177
|
+
// board; nothing decides anything from it any more.
|
|
161
178
|
const { rows: made } = await exec.query(
|
|
162
|
-
`INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order)
|
|
163
|
-
SELECT $1, $2, $3, $4, 'open', NULL, COALESCE(MAX(sort_order) + 1, 0)
|
|
179
|
+
`INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order, is_maintenance)
|
|
180
|
+
SELECT $1, $2, $3, $4, 'open', NULL, COALESCE(MAX(sort_order) + 1, 0), true
|
|
164
181
|
FROM goals WHERE version_id = $1
|
|
165
182
|
RETURNING ${GOAL_COLS}`,
|
|
166
183
|
[versionId, title,
|
|
167
184
|
`Where a reported defect lands when its reporter named no goal (ADR 0262). ` +
|
|
168
|
-
`Auto-created by the bug-routing path
|
|
169
|
-
`the
|
|
170
|
-
`forward to the successor version.`,
|
|
185
|
+
`Auto-created by the bug-routing path, flagged \`is_maintenance\` so it is exempt ` +
|
|
186
|
+
`from the version-close count and never holds a version open (ADR 0263 §7). Its ` +
|
|
187
|
+
`unfinished bugs carry forward to the successor version's maintenance goal.`,
|
|
171
188
|
MAINTENANCE_SCOPE_MODULES]
|
|
172
189
|
);
|
|
173
190
|
return made[0] || null;
|
|
174
191
|
}
|
|
175
192
|
|
|
193
|
+
// carryBugsForward — move a closing version's unfinished defects into the
|
|
194
|
+
// successor's maintenance goal (BV1.R18, task 1003605, goal 1000086, ADR 0263 §7).
|
|
195
|
+
//
|
|
196
|
+
// THE ONE GOAL THAT LEGITIMATELY SPANS VERSIONS. Every other goal is scoped to a
|
|
197
|
+
// version and dies with it; the maintenance goal is where a defect against
|
|
198
|
+
// already-shipped work lands, and defects do not respect a version boundary. ADR
|
|
199
|
+
// 0250 §3 makes that a pressure valve — a bug must never need an Archon override
|
|
200
|
+
// to be filed — and ADR 0263 §7 points out the valve only works if the queue
|
|
201
|
+
// SURVIVES the boundary. A version close that left the open bugs in a shipped
|
|
202
|
+
// version's goal would entomb them exactly where nobody looks, which is the 1000771
|
|
203
|
+
// bug this whole path was built to end.
|
|
204
|
+
//
|
|
205
|
+
// RUNS ON THE CALLER'S CLIENT, inside the close transaction, and AFTER the
|
|
206
|
+
// successor has been promoted to `building` — `ensureMaintenanceGoal` returns null
|
|
207
|
+
// for a version that is not building, so a carry-over run before the promotion
|
|
208
|
+
// silently carries nothing. That ordering is R20's (task 1003607) to keep; this
|
|
209
|
+
// function fails loudly rather than quietly if it is broken.
|
|
210
|
+
//
|
|
211
|
+
// IT DOES NOT REFUSE AN ACTIVELY-CLAIMED TASK, and that is a deliberate divergence
|
|
212
|
+
// from `archiveGoalWithDispositions` (R14), which does. The two are different acts:
|
|
213
|
+
// archiving ABANDONS a task out from under whoever is working it, so refusing
|
|
214
|
+
// protects them. Carrying forward only re-points which goal the task belongs to —
|
|
215
|
+
// the claim references the task and is untouched, the builder loses nothing — and
|
|
216
|
+
// refusing here would let one in-flight bug fix block a version from ever closing.
|
|
217
|
+
// A close must not be hostage to a claim on a defect.
|
|
218
|
+
//
|
|
219
|
+
// Returns { fromGoalId, toGoalId, moved } — `moved` is 0 whenever there is nothing
|
|
220
|
+
// to carry, which is the common case and is not an error.
|
|
221
|
+
async function carryBugsForward({ fromVersionId, toVersionId }, deps = {}) {
|
|
222
|
+
const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
|
|
223
|
+
if (fromVersionId == null || toVersionId == null) return { fromGoalId: null, toGoalId: null, moved: 0 };
|
|
224
|
+
|
|
225
|
+
// The source goal is read by FLAG, not created: a version that never received a
|
|
226
|
+
// homeless bug has no maintenance goal, and inventing one on the way out would
|
|
227
|
+
// leave an empty goal on a shipped version forever.
|
|
228
|
+
const { rows: from } = await exec.query(
|
|
229
|
+
`SELECT id FROM goals WHERE version_id = $1 AND is_maintenance ORDER BY id LIMIT 1`,
|
|
230
|
+
[fromVersionId]
|
|
231
|
+
);
|
|
232
|
+
if (!from[0]) return { fromGoalId: null, toGoalId: null, moved: 0 };
|
|
233
|
+
|
|
234
|
+
const to = await ensureMaintenanceGoal(toVersionId, deps);
|
|
235
|
+
if (!to) {
|
|
236
|
+
throw Object.assign(new Error('successor version cannot hold a maintenance goal'), {
|
|
237
|
+
code: 'CARRY_FORWARD_NO_DESTINATION', toVersionId,
|
|
238
|
+
hint: 'ensureMaintenanceGoal returns null unless the destination version is `building` — promote it before carrying forward (ADR 0263 §8).',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Only the UNFINISHED ones move. A shipped or abandoned defect is history and
|
|
243
|
+
// belongs to the version it was fixed in — dragging it forward would make every
|
|
244
|
+
// successor's maintenance goal a growing archive of everything ever reported.
|
|
245
|
+
const { rows: moved } = await exec.query(
|
|
246
|
+
`UPDATE tasks SET goal_id = $2, updated_at = now()
|
|
247
|
+
WHERE goal_id = $1 AND ${nonTerminalSql('status')}
|
|
248
|
+
RETURNING id::text`,
|
|
249
|
+
[from[0].id, to.id]
|
|
250
|
+
);
|
|
251
|
+
return { fromGoalId: String(from[0].id), toGoalId: String(to.id), moved: moved.length };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// carryMaintenanceForward — move a closing version's unfinished maintenance work
|
|
255
|
+
// into the successor's maintenance goal (BV1.R18, task 1003605, ADR 0263 §7).
|
|
256
|
+
//
|
|
257
|
+
// THE ONE GOAL THAT LEGITIMATELY SPANS VERSIONS. Every other goal either finishes
|
|
258
|
+
// or is dispositioned at close (roll forward, or abandon — R12). A maintenance
|
|
259
|
+
// goal is neither: it holds defects against already-shipped work, and those arrive
|
|
260
|
+
// on their own schedule rather than on the version's. Cutting them because a
|
|
261
|
+
// version ended would delete a bug queue for a reason that has nothing to do with
|
|
262
|
+
// the bugs; making them need an override to survive would put a counted escape
|
|
263
|
+
// hatch on the ordinary path, which is what ADR 0250 §3 built the counter to
|
|
264
|
+
// prevent. So they move.
|
|
265
|
+
//
|
|
266
|
+
// RUNS ON THE CLOSE'S OWN CLIENT, inside its transaction, via the `deps.onClosed`
|
|
267
|
+
// seam R12 left in closeVersion. Two things follow, both load-bearing:
|
|
268
|
+
//
|
|
269
|
+
// The successor must ALREADY be `building` when this runs. `ensureMaintenanceGoal`
|
|
270
|
+
// returns null for a version in any other state, so a carry-over run before R20's
|
|
271
|
+
// promotion silently carries nothing and reports success. That is why the seam
|
|
272
|
+
// fires after the flip and why this asserts rather than assumes.
|
|
273
|
+
//
|
|
274
|
+
// Nothing here may fail the close for want of a destination. A version with no
|
|
275
|
+
// successor (no planning version was scoped) closes with its maintenance work
|
|
276
|
+
// left where it is — visible on a shipped version, recoverable, and honest —
|
|
277
|
+
// rather than the close failing or the bugs being cut. Returns a summary saying
|
|
278
|
+
// which happened.
|
|
279
|
+
//
|
|
280
|
+
// The source goal is left ARCHIVED with nothing open, so it passes R10's own
|
|
281
|
+
// check on the way out rather than being special-cased around it.
|
|
282
|
+
async function carryMaintenanceForward({ fromVersionId, toVersionId }, deps = {}) {
|
|
283
|
+
const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
|
|
284
|
+
const { rows: src } = await exec.query(
|
|
285
|
+
`SELECT id FROM goals WHERE version_id = $1 AND is_maintenance LIMIT 1`, [fromVersionId]
|
|
286
|
+
);
|
|
287
|
+
if (!src[0]) return { carried: 0, from: null, to: null, reason: 'no_maintenance_goal' };
|
|
288
|
+
if (!toVersionId) return { carried: 0, from: String(src[0].id), to: null, reason: 'no_successor' };
|
|
289
|
+
|
|
290
|
+
const dest = await ensureMaintenanceGoal(toVersionId, { client: exec });
|
|
291
|
+
// Null means the successor is not `building` — see the ordering note above. Do
|
|
292
|
+
// not "fix" this by relaxing ensureMaintenanceGoal's version gate: that gate is
|
|
293
|
+
// what stops a defect being filed into a shipped version's goal (the 1000771 bug).
|
|
294
|
+
if (!dest) return { carried: 0, from: String(src[0].id), to: null, reason: 'successor_not_building' };
|
|
295
|
+
|
|
296
|
+
const { rows: moved } = await exec.query(
|
|
297
|
+
`UPDATE tasks SET goal_id = $2, version_id = $3, updated_at = now()
|
|
298
|
+
WHERE goal_id = $1 AND ${nonTerminalSql()}
|
|
299
|
+
AND ${SMOKE_NOT_LIKE.replace(/title/, 'tasks.title')}
|
|
300
|
+
RETURNING id`,
|
|
301
|
+
[src[0].id, dest.id, toVersionId]
|
|
302
|
+
);
|
|
303
|
+
// The emptied goal closes behind them. Archived rather than achieved: it was
|
|
304
|
+
// never "done", it stopped being the live queue.
|
|
305
|
+
await exec.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [src[0].id]);
|
|
306
|
+
return { carried: moved.length, from: String(src[0].id), to: String(dest.id), reason: null };
|
|
307
|
+
}
|
|
308
|
+
|
|
176
309
|
// listVersionAdmissions (BV1.R07, task 1003594) — every goal admitted into ONE
|
|
177
310
|
// version, newest first. ADR 0250 §3: "the overrides are counted and shown on the
|
|
178
311
|
// version" — a visible counter is what keeps the exception honest, because an
|
|
@@ -1092,8 +1225,10 @@ async function listPendingRequestsForGoal(goalId, deps = {}) {
|
|
|
1092
1225
|
module.exports = {
|
|
1093
1226
|
acceptMembershipRequestAndAddMember,
|
|
1094
1227
|
ensureMaintenanceGoal,
|
|
1228
|
+
carryBugsForward,
|
|
1095
1229
|
achieveGoalIfComplete,
|
|
1096
1230
|
archiveGoalWithDispositions,
|
|
1231
|
+
carryMaintenanceForward,
|
|
1097
1232
|
addGoalMember,
|
|
1098
1233
|
addGoalScopeModules,
|
|
1099
1234
|
createGoal,
|
|
@@ -190,19 +190,19 @@ async function currentBuildingVersionId(deps = {}) {
|
|
|
190
190
|
// maintenanceGoalExemptSql — "this goal is NOT the version's maintenance goal",
|
|
191
191
|
// as one SQL predicate with ONE definition (BV1.R12, task 1003599; ADR 0263 §7).
|
|
192
192
|
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
193
|
+
// R18 (task 1003605) REPLACED THE BODY, exactly as R12 said it would, and the
|
|
194
|
+
// seam is why that was a one-line change with no call sites to hunt. It used to
|
|
195
|
+
// read `g.title <> ($1 || ' — maintenance')`, which decided by STRING which goals
|
|
196
|
+
// hold a version open — so any Metic+ able to create a goal could name it
|
|
197
|
+
// `"BONGOS-V1 — maintenance"` and silently exempt it from the gate that decides
|
|
198
|
+
// when a version may close. Widening scope by choosing a title is not a wrinkle,
|
|
199
|
+
// it is a way in. `goals.is_maintenance` (migration core_234) cannot be spelled
|
|
200
|
+
// into existence.
|
|
200
201
|
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return `g.title <> (${titleParam} || ' — maintenance')`;
|
|
202
|
+
// The parameter is now unused and kept ONLY so the call sites do not change
|
|
203
|
+
// shape; drop it the next time this file is touched for another reason.
|
|
204
|
+
function maintenanceGoalExemptSql(_titleParam) {
|
|
205
|
+
return 'NOT g.is_maintenance';
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
// openNonMaintenanceGoals — the goals that hold a version open, id + title +
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -71,6 +71,7 @@ const {
|
|
|
71
71
|
addGoalScopeModules,
|
|
72
72
|
createGoal,
|
|
73
73
|
ensureMaintenanceGoal,
|
|
74
|
+
carryBugsForward,
|
|
74
75
|
createMembershipRequest,
|
|
75
76
|
getGoal,
|
|
76
77
|
getGoalAdmission,
|
|
@@ -82,6 +83,7 @@ const {
|
|
|
82
83
|
listActiveClaimsForTasks,
|
|
83
84
|
listCriteriaForGoal,
|
|
84
85
|
archiveGoalWithDispositions,
|
|
86
|
+
carryMaintenanceForward,
|
|
85
87
|
openTasksInGoal,
|
|
86
88
|
listGoalMembers,
|
|
87
89
|
listGoals,
|
|
@@ -225,6 +227,7 @@ module.exports = {
|
|
|
225
227
|
currentBuildingVersionId,
|
|
226
228
|
createGoal,
|
|
227
229
|
ensureMaintenanceGoal,
|
|
230
|
+
carryBugsForward,
|
|
228
231
|
getGoal,
|
|
229
232
|
getGoalAdmission,
|
|
230
233
|
listVersionAdmissions,
|
|
@@ -257,6 +260,7 @@ module.exports = {
|
|
|
257
260
|
listPendingRequestsForGoal,
|
|
258
261
|
listCriteriaForGoal,
|
|
259
262
|
archiveGoalWithDispositions,
|
|
263
|
+
carryMaintenanceForward,
|
|
260
264
|
openTasksInGoal,
|
|
261
265
|
createTask,
|
|
262
266
|
generalGoalIdForVersion,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.597",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.597",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.597",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.597'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -90,16 +90,6 @@ function grantBlocksForRole(role) {
|
|
|
90
90
|
const REVOKE_RE =
|
|
91
91
|
/DELETE\s+FROM\s+govern(?:ance_role|ment_rank)_permissions\s+WHERE\s+(?:rank_key|role_key)\s*=\s*'([^']+)'\s+AND\s+permission_key\s*=\s*'([^']+)'/gi;
|
|
92
92
|
|
|
93
|
-
function revokeBlocksForRole(role) {
|
|
94
|
-
const blocks = [];
|
|
95
|
-
for (const [file, sql] of grantSqlByFile) {
|
|
96
|
-
const code = sql.replace(/--[^\n]*/g, '');
|
|
97
|
-
for (const m of code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))) {
|
|
98
|
-
if (m[1] === role) blocks.push({ file, key: m[2] });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return blocks;
|
|
102
|
-
}
|
|
103
93
|
|
|
104
94
|
// The EFFECTIVE grant set: walk the migrations in filename order, adding each
|
|
105
95
|
// file's grants and removing its revocations, so a key granted early and revoked
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// tests/maintenance_carry_forward.mjs — the one goal that spans versions
|
|
2
|
+
// (BV1.R18b, task 1003708, goal 1000086, ADR 0263 §7).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS GOT A COLUMN. Since task 1003691 the maintenance goal was found by
|
|
5
|
+
// matching `'<version> — maintenance'`. For the ROUTING path that was a
|
|
6
|
+
// provenance wrinkle — a hand-titled goal receives a bug. For the CLOSING path
|
|
7
|
+
// it was a way through the gate: `maintenanceGoalExemptSql` decides which goals
|
|
8
|
+
// hold a VERSION open, so anyone who named a goal `'BONGOS-V1 — maintenance'`
|
|
9
|
+
// exempted it from the rule that decides when a version may close.
|
|
10
|
+
//
|
|
11
|
+
// A naming convention is not an access control. That sentence is the whole task,
|
|
12
|
+
// and most of this file is that sentence made mechanical.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/maintenance_flag.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
process.env.NODE_ENV = 'test';
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { maintenanceGoalExemptSql, ensureMaintenanceGoal, carryMaintenanceForward, openNonMaintenanceGoals } = require('../modules/lifecycle/db.js');
|
|
24
|
+
const { test, summary } = makeRunner();
|
|
25
|
+
|
|
26
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
27
|
+
const MIG = src('migrations/core_235_one_maintenance_goal_per_version.sql');
|
|
28
|
+
const MIG234 = src('migrations/core_234_goals_is_maintenance.sql');
|
|
29
|
+
const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
|
|
30
|
+
function pool(handler) {
|
|
31
|
+
const c = makeSqlAwareClient(handler);
|
|
32
|
+
return { connect: c.connect, query: (sql, p) => c._client.query(sql, p), queries: c._client.queries };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- one per version is a CONSTRAINT ---------------------------------------
|
|
36
|
+
|
|
37
|
+
await test('the per-version uniqueness is enforced by the DATABASE, not a convention', () => {
|
|
38
|
+
// core_234 gave the flag a partial index for lookup speed, which is right. But a
|
|
39
|
+
// NON-unique one leaves two flagged goals on one version possible, and
|
|
40
|
+
// ensureMaintenanceGoal's read is `WHERE version_id = $1 AND is_maintenance
|
|
41
|
+
// LIMIT 1` — with two rows that returns whichever the planner picks, and every
|
|
42
|
+
// homeless bug files into an arbitrary one of them, permanently.
|
|
43
|
+
assert.match(MIG, /CREATE UNIQUE INDEX IF NOT EXISTS goals_one_maintenance_per_version_idx[\s\S]*?ON goals \(version_id\)[\s\S]*?WHERE is_maintenance/);
|
|
44
|
+
assert.match(MIG, /DROP INDEX IF EXISTS goals_maintenance_by_version_idx/,
|
|
45
|
+
'and it replaces the plain one — a UNIQUE index serves every read the plain one did');
|
|
46
|
+
assert.match(MIG, /BEGIN;[\s\S]*COMMIT;/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await test('it closes the door the advisory lock could not', () => {
|
|
50
|
+
// The lock (task 1003691) serialises the RACE. It cannot stop a second
|
|
51
|
+
// maintenance goal created any other way, and `goals` never had a unique
|
|
52
|
+
// constraint on (version_id, title) for the lock to fall back on.
|
|
53
|
+
const dbg = src('modules/lifecycle/db-goals.js');
|
|
54
|
+
assert.match(dbg, /pg_advisory_xact_lock/, 'the lock stays — it solves a different problem');
|
|
55
|
+
assert.match(MIG234, /is_maintenance boolean NOT NULL DEFAULT false/, 'core_234 still owns the column');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---- carry forward ---------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function carryPool({ src = { id: 5 }, destStatus = 'building', destFound = { id: 9 } } = {}) {
|
|
61
|
+
return pool((sql) => {
|
|
62
|
+
const q = String(sql).replace(/\s+/g, ' ');
|
|
63
|
+
if (/SELECT id FROM goals WHERE version_id = \$1 AND is_maintenance LIMIT 1/.test(q)) return { rows: src ? [src] : [] };
|
|
64
|
+
if (/SELECT status FROM versions/.test(q)) return { rows: [{ status: destStatus }] };
|
|
65
|
+
if (/pg_advisory_xact_lock/.test(q)) return { rows: [] };
|
|
66
|
+
if (/FROM goals WHERE version_id = \$1 AND is_maintenance/.test(q)) return { rows: destFound ? [destFound] : [] };
|
|
67
|
+
if (/UPDATE tasks SET goal_id/.test(q)) return { rows: [{ id: 1 }, { id: 2 }, { id: 3 }] };
|
|
68
|
+
return { rows: [] };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await test('open bugs move to the successor, and the emptied goal archives behind them', async () => {
|
|
73
|
+
const p = carryPool();
|
|
74
|
+
const out = await carryMaintenanceForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { pool: p });
|
|
75
|
+
assert.deepEqual(out, { carried: 3, from: '5', to: '9', reason: null });
|
|
76
|
+
const mv = p.queries.find((x) => /UPDATE tasks SET goal_id/.test(flat(x)));
|
|
77
|
+
assert.match(flat(mv), /NOT IN \('shipped', 'abandoned'\)/, 'only the unfinished ones travel');
|
|
78
|
+
assert.equal(mv.params[2], 'V2', 'and their version_id moves with them, or they are orphaned across the boundary');
|
|
79
|
+
assert.ok(p.queries.some((x) => /UPDATE goals SET status = 'archived'/.test(flat(x))));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await test('NO successor: the bugs stay put and the close still succeeds', async () => {
|
|
83
|
+
// A version with nothing scoped after it must not fail its close, and its bug
|
|
84
|
+
// queue must not be cut for a reason that has nothing to do with the bugs.
|
|
85
|
+
const p = carryPool();
|
|
86
|
+
const out = await carryMaintenanceForward({ fromVersionId: 'V1', toVersionId: null }, { pool: p });
|
|
87
|
+
assert.equal(out.carried, 0);
|
|
88
|
+
assert.equal(out.reason, 'no_successor');
|
|
89
|
+
assert.equal(p.queries.some((x) => /UPDATE tasks/.test(flat(x))), false, 'nothing moved');
|
|
90
|
+
assert.equal(p.queries.some((x) => /UPDATE goals SET status = 'archived'/.test(flat(x))), false,
|
|
91
|
+
'and the source goal is NOT closed — the queue is still live');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('a successor that is not yet BUILDING carries nothing, and says so', async () => {
|
|
95
|
+
// The ordering trap ADR 0263 §8 names: ensureMaintenanceGoal returns null for a
|
|
96
|
+
// non-building version, so a carry-over run before R20's promotion would report
|
|
97
|
+
// success while moving nothing.
|
|
98
|
+
const p = carryPool({ destStatus: 'planning', destFound: null });
|
|
99
|
+
const out = await carryMaintenanceForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { pool: p });
|
|
100
|
+
assert.equal(out.carried, 0);
|
|
101
|
+
assert.equal(out.reason, 'successor_not_building', 'a silent zero would be indistinguishable from success');
|
|
102
|
+
assert.equal(p.queries.some((x) => /UPDATE tasks/.test(flat(x))), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await test('a version with no maintenance goal at all is a clean no-op', async () => {
|
|
106
|
+
const p = carryPool({ src: null });
|
|
107
|
+
const out = await carryMaintenanceForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { pool: p });
|
|
108
|
+
assert.equal(out.reason, 'no_maintenance_goal');
|
|
109
|
+
assert.equal(out.carried, 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await test('it runs on the CALLER exec, so it joins the close transaction', () => {
|
|
113
|
+
// R12 left `deps.onClosed(client, …)` for exactly this. Opening its own
|
|
114
|
+
// connection would commit the carry-over independently of the close it belongs to.
|
|
115
|
+
const dbg = src('modules/lifecycle/db-goals.js');
|
|
116
|
+
const fn = dbg.slice(dbg.indexOf('async function carryMaintenanceForward'), dbg.indexOf('// listVersionAdmissions'));
|
|
117
|
+
assert.match(fn, /const exec = deps\.client \|\|/, 'it must accept the caller\'s client');
|
|
118
|
+
assert.doesNotMatch(fn, /\.connect\(\)/, 'and must never open its own');
|
|
119
|
+
assert.doesNotMatch(fn, /BEGIN|COMMIT/, 'nor manage a transaction it does not own');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
summary();
|
|
@@ -96,9 +96,15 @@ await test('ensureMaintenanceGoal: an EXISTING goal is returned, and nothing is
|
|
|
96
96
|
const goal = await ensureMaintenanceGoal('BONGOS-V1', { pool });
|
|
97
97
|
assert.equal(goal.id, 77);
|
|
98
98
|
assert.ok(!find(pool.queries, /INSERT INTO goals/), 'find-or-create must not create when it finds');
|
|
99
|
-
//
|
|
99
|
+
// BV1.R18 (task 1003605, migration core_234): the lookup is keyed on the
|
|
100
|
+
// VERSION and the `is_maintenance` flag — the title is no longer a parameter,
|
|
101
|
+
// because the title no longer decides anything. It used to bind
|
|
102
|
+
// ['BONGOS-V1', 'BONGOS-V1 — maintenance'].
|
|
100
103
|
const lookup = find(pool.queries, /^SELECT .* FROM goals WHERE version_id/);
|
|
101
|
-
assert.deepEqual(lookup.params, ['BONGOS-V1'
|
|
104
|
+
assert.deepEqual(lookup.params, ['BONGOS-V1']);
|
|
105
|
+
assert.match(lookup.sql, /is_maintenance/);
|
|
106
|
+
assert.equal(/title/.test(lookup.sql.replace(/SELECT[\s\S]*?FROM/, 'SELECT FROM')), false,
|
|
107
|
+
'the title must not appear in the WHERE clause — a goal named into the convention would otherwise be adopted');
|
|
102
108
|
});
|
|
103
109
|
|
|
104
110
|
await test('ensureMaintenanceGoal: the LOCK is taken BEFORE the read, or find-or-create races', async () => {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// tests/maintenance_goal_flag.mjs — which goal is the maintenance goal is a
|
|
2
|
+
// COLUMN, not a title (BV1.R18, task 1003605, goal 1000086, ADR 0263 §7).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS MATTERS MORE THAN IT LOOKS. The maintenance goal was identified by
|
|
5
|
+
// matching the title `"<version> — maintenance"`. For ROUTING a homeless bug that
|
|
6
|
+
// was a provenance wrinkle: the worst case is a hand-titled goal receiving a
|
|
7
|
+
// defect. Then R12 made the SAME identification decide the version-close count —
|
|
8
|
+
// `maintenanceGoalExemptSql` answers "which goals hold this version open" — and a
|
|
9
|
+
// wrinkle became a way in: any Metic+ able to create a goal could exempt it from
|
|
10
|
+
// the gate that decides when a version may close, by choosing a string.
|
|
11
|
+
// `goals.is_maintenance` (migration core_234) cannot be spelled into existence.
|
|
12
|
+
//
|
|
13
|
+
// The BACKFILL is the one place the title match legitimately survives: the rows
|
|
14
|
+
// that already exist were made by the convention, so the convention identifies
|
|
15
|
+
// them — once, in the migration, with the schema carrying the fact afterwards.
|
|
16
|
+
//
|
|
17
|
+
// Run: node --test tests/maintenance_goal_flag.mjs
|
|
18
|
+
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { makeSqlAwareClient } from './helpers.mjs';
|
|
24
|
+
|
|
25
|
+
process.env.NODE_ENV = 'test';
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const { ensureMaintenanceGoal, carryBugsForward } = require('../modules/lifecycle/db.js');
|
|
28
|
+
const { maintenanceGoalExemptSql } = require('../modules/lifecycle/db-versions.js');
|
|
29
|
+
|
|
30
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
31
|
+
const MIGRATION = src('migrations/core_234_goals_is_maintenance.sql');
|
|
32
|
+
const DB_GOALS = src('modules/lifecycle/db-goals.js');
|
|
33
|
+
|
|
34
|
+
function recorder(handler) {
|
|
35
|
+
const seen = [];
|
|
36
|
+
const c = makeSqlAwareClient((sql, params) => { seen.push({ sql, params }); return handler(sql, params); });
|
|
37
|
+
return { client: { query: (sql, params) => c._client.query(sql, params) }, seen };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// The predicate that decides which goals hold a version open
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
test('the close-count exemption reads the FLAG, never a title', () => {
|
|
45
|
+
const sql = maintenanceGoalExemptSql('$1');
|
|
46
|
+
assert.equal(sql, 'NOT g.is_maintenance');
|
|
47
|
+
// The specific regression: any form of title comparison here lets a goal
|
|
48
|
+
// exempt itself from the version-close gate by being named a certain way.
|
|
49
|
+
assert.equal(/title/i.test(sql), false,
|
|
50
|
+
'a title comparison here is a scope widening anyone who can name a goal could perform');
|
|
51
|
+
assert.equal(/maintenance'/.test(sql), false, 'no string literal decides this');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// find-or-create
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
test('ensureMaintenanceGoal FINDS by flag, not by title', async () => {
|
|
59
|
+
const r = recorder((sql) => {
|
|
60
|
+
if (/FROM versions/.test(sql)) return { rows: [{ status: 'building' }] };
|
|
61
|
+
if (/pg_advisory_xact_lock/.test(sql)) return { rows: [] };
|
|
62
|
+
if (/^SELECT .* FROM goals/m.test(sql)) return { rows: [{ id: 7, is_maintenance: true }] };
|
|
63
|
+
return { rows: [] };
|
|
64
|
+
});
|
|
65
|
+
const got = await ensureMaintenanceGoal('BONGOS-V1', { client: r.client });
|
|
66
|
+
assert.equal(got.id, 7);
|
|
67
|
+
const lookup = r.seen.find((q) => /FROM goals/.test(q.sql) && /SELECT/.test(q.sql));
|
|
68
|
+
assert.match(lookup.sql, /WHERE version_id = \$1 AND is_maintenance/);
|
|
69
|
+
assert.equal(/title = \$2/.test(lookup.sql), false, 'the title must no longer select the goal');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the advisory lock is still taken BEFORE the read', async () => {
|
|
73
|
+
// The lock is what makes find-or-create safe without a unique constraint, and
|
|
74
|
+
// it is keyed on the title purely as a stable per-version string. Moving the
|
|
75
|
+
// identification to a flag must not accidentally drop the serialisation.
|
|
76
|
+
const r = recorder((sql) => {
|
|
77
|
+
if (/FROM versions/.test(sql)) return { rows: [{ status: 'building' }] };
|
|
78
|
+
return { rows: [{ id: 7 }] };
|
|
79
|
+
});
|
|
80
|
+
await ensureMaintenanceGoal('BONGOS-V1', { client: r.client });
|
|
81
|
+
const lockAt = r.seen.findIndex((q) => /pg_advisory_xact_lock/.test(q.sql));
|
|
82
|
+
const readAt = r.seen.findIndex((q) => /FROM goals/.test(q.sql) && /is_maintenance/.test(q.sql));
|
|
83
|
+
assert.ok(lockAt !== -1, 'the advisory lock must still be taken');
|
|
84
|
+
assert.ok(lockAt < readAt, 'the lock must precede the read, or two writers both see "none"');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a created maintenance goal is flagged in the SAME statement that creates it', () => {
|
|
88
|
+
// Not a follow-up UPDATE: a maintenance goal must never exist unflagged, even
|
|
89
|
+
// for one transaction, or a concurrent close count would not exempt it.
|
|
90
|
+
const insert = DB_GOALS.slice(DB_GOALS.indexOf('INSERT INTO goals (version_id, title, description'));
|
|
91
|
+
assert.match(insert.slice(0, 400), /is_maintenance/);
|
|
92
|
+
assert.match(insert.slice(0, 400), /true/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a version that cannot hold work gets no maintenance goal', async () => {
|
|
96
|
+
for (const status of ['shipped', 'planning', 'frozen']) {
|
|
97
|
+
const r = recorder(() => ({ rows: [{ status }] }));
|
|
98
|
+
assert.equal(await ensureMaintenanceGoal('V', { client: r.client }), null, `status=${status}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// The carry-over — the one goal that legitimately spans versions
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
test('carryBugsForward moves only NON-TERMINAL tasks', async () => {
|
|
107
|
+
const r = recorder((sql) => {
|
|
108
|
+
// `SELECT id FROM goals` is carryBugsForward's own source read;
|
|
109
|
+
// `SELECT id, version_id, …` is ensureMaintenanceGoal's GOAL_COLS read.
|
|
110
|
+
// The sql-aware client collapses whitespace, so the two must be told apart
|
|
111
|
+
// by their projection rather than by line breaks.
|
|
112
|
+
if (/SELECT id FROM goals/.test(sql)) return { rows: [{ id: 1 }] };
|
|
113
|
+
if (/FROM versions/.test(sql)) return { rows: [{ status: 'building' }] };
|
|
114
|
+
if (/pg_advisory_xact_lock/.test(sql)) return { rows: [] };
|
|
115
|
+
if (/SELECT id, version_id/.test(sql)) return { rows: [{ id: 5 }] };
|
|
116
|
+
if (/UPDATE tasks/.test(sql)) return { rows: [{ id: '11' }, { id: '12' }] };
|
|
117
|
+
return { rows: [] };
|
|
118
|
+
});
|
|
119
|
+
const out = await carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client });
|
|
120
|
+
assert.deepEqual(out, { fromGoalId: '1', toGoalId: '5', moved: 2 });
|
|
121
|
+
const move = r.seen.find((q) => /UPDATE tasks/.test(q.sql));
|
|
122
|
+
assert.match(move.sql, /status NOT IN \('shipped', 'abandoned'\)/,
|
|
123
|
+
'a fixed or cut defect belongs to the version it was resolved in — carrying it forward would make every successor an archive');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a version with no maintenance goal carries nothing, and that is not an error', async () => {
|
|
127
|
+
const r = recorder(() => ({ rows: [] }));
|
|
128
|
+
const out = await carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client });
|
|
129
|
+
assert.deepEqual(out, { fromGoalId: null, toGoalId: null, moved: 0 });
|
|
130
|
+
assert.equal(r.seen.some((q) => /UPDATE tasks/.test(q.sql)), false, 'nothing is written');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('carrying into a version that is not building FAILS LOUDLY', async () => {
|
|
134
|
+
// ADR 0263 §8: promotion must happen BEFORE the carry-over, because
|
|
135
|
+
// ensureMaintenanceGoal returns null for a version that is not `building`. A
|
|
136
|
+
// silent no-op here would strand the bug queue on a shipped version — the
|
|
137
|
+
// exact entombment this whole path exists to end — and look like success.
|
|
138
|
+
const r = recorder((sql) => {
|
|
139
|
+
if (/SELECT id FROM goals/.test(sql)) return { rows: [{ id: 1 }] };
|
|
140
|
+
if (/FROM versions/.test(sql)) return { rows: [{ status: 'planning' }] };
|
|
141
|
+
return { rows: [] };
|
|
142
|
+
});
|
|
143
|
+
await assert.rejects(
|
|
144
|
+
() => carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client }),
|
|
145
|
+
(e) => e.code === 'CARRY_FORWARD_NO_DESTINATION'
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('carryBugsForward does NOT refuse an actively-claimed task', () => {
|
|
150
|
+
// A deliberate divergence from archiveGoalWithDispositions (R14), which does.
|
|
151
|
+
// Archiving abandons a task out from under whoever holds it; carrying forward
|
|
152
|
+
// only re-points its goal, and the claim references the task, so nothing is
|
|
153
|
+
// taken from anyone. Refusing here would let one in-flight bug fix block a
|
|
154
|
+
// version from ever closing.
|
|
155
|
+
const fn = DB_GOALS.slice(DB_GOALS.indexOf('async function carryBugsForward'));
|
|
156
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
157
|
+
assert.equal(/FROM claims/.test(body), false, 'a close must not be hostage to a claim on a defect');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// The property the whole task exists for: the flag has no user write path
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
test('NOTHING but the system create can set is_maintenance', () => {
|
|
165
|
+
// THE POINT OF THE COLUMN. Moving the identification from a title to a flag
|
|
166
|
+
// only closes the vector if the flag itself cannot be set by whoever could
|
|
167
|
+
// previously choose the title. Every `UPDATE goals` in the lifecycle names its
|
|
168
|
+
// columns explicitly — there is no generic passthrough — so this asserts that
|
|
169
|
+
// none of them names this one, and that the sole writer is the system
|
|
170
|
+
// find-or-create (`created_by NULL`) plus the one-time migration backfill.
|
|
171
|
+
//
|
|
172
|
+
// If a future edit adds a goal-field update that accepts arbitrary columns,
|
|
173
|
+
// this is what should stop it.
|
|
174
|
+
const files = [
|
|
175
|
+
'modules/lifecycle/db-goals.js',
|
|
176
|
+
'modules/lifecycle/db-versions.js',
|
|
177
|
+
'modules/lifecycle/goal-edits.js',
|
|
178
|
+
'modules/lifecycle/goal-category.js',
|
|
179
|
+
'modules/lifecycle/done-when.js',
|
|
180
|
+
];
|
|
181
|
+
for (const rel of files) {
|
|
182
|
+
const code = src(rel).replace(/\/\/[^\n]*/g, '');
|
|
183
|
+
for (const m of code.matchAll(/UPDATE goals[\s\S]{0,400}?(?=RETURNING|WHERE id|;|`)/g)) {
|
|
184
|
+
assert.equal(/\bis_maintenance\s*=/.test(m[0]), false,
|
|
185
|
+
`${rel}: an UPDATE sets is_maintenance — the flag must have no write path a caller can reach, or naming a goal is replaced by flagging one`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// The migration
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
test('the column is NOT NULL with a false default, so no goal is ambiguously flagged', () => {
|
|
195
|
+
assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS is_maintenance boolean NOT NULL DEFAULT false/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('the backfill is anchored on the version id, not a bare LIKE', () => {
|
|
199
|
+
// `title LIKE '%maintenance%'` would have flagged a goal legitimately called
|
|
200
|
+
// "Maintenance of the art pipeline" and silently exempted it from the close
|
|
201
|
+
// gate — the very failure the column exists to remove.
|
|
202
|
+
assert.match(MIGRATION, /title = version_id \|\| ' — maintenance'/);
|
|
203
|
+
// Comments stripped first: this file's own header explains why a bare
|
|
204
|
+
// `LIKE '%maintenance%'` would be wrong, and a check that read the prose
|
|
205
|
+
// would fail on the explanation rather than on the SQL.
|
|
206
|
+
const sqlOnly = MIGRATION.replace(/--[^\n]*/g, '');
|
|
207
|
+
assert.equal(/LIKE\s*'%maintenance/i.test(sqlOnly), false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('the migration records WHY there is no unique constraint yet', () => {
|
|
211
|
+
// A partial unique index would be tidy and would FAIL on any database that
|
|
212
|
+
// already holds two title-matching goals for one version — which is possible,
|
|
213
|
+
// because `goals` has no unique constraint on (version_id, title). Same lesson
|
|
214
|
+
// core_233 recorded about the building-version index.
|
|
215
|
+
assert.match(MIGRATION, /NOT UNIQUE PER VERSION/);
|
|
216
|
+
assert.match(MIGRATION, /core_233/, 'the precedent is named so the reasoning is findable');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('the find-or-create survives a pre-existing duplicate rather than throwing', () => {
|
|
220
|
+
const fn = DB_GOALS.slice(DB_GOALS.indexOf('async function ensureMaintenanceGoal'));
|
|
221
|
+
assert.match(fn.slice(0, 2500), /ORDER BY id LIMIT 1/,
|
|
222
|
+
'with no unique constraint, a duplicate must be survivable and the answer stable');
|
|
223
|
+
});
|
|
@@ -130,9 +130,16 @@ await test('roll_forward WITH a planning version proceeds', () => {
|
|
|
130
130
|
// ---- the maintenance exemption ---------------------------------------------
|
|
131
131
|
|
|
132
132
|
await test('the exemption has ONE definition, and the open-goals read is its only caller', () => {
|
|
133
|
-
// R18 (task 1003605)
|
|
134
|
-
//
|
|
135
|
-
|
|
133
|
+
// R18 (task 1003605, migration core_234) SWAPPED this body, exactly as R12
|
|
134
|
+
// predicted here — the single definition is what made it a one-line change
|
|
135
|
+
// instead of a hunt, and this assertion moved with it.
|
|
136
|
+
//
|
|
137
|
+
// The old body was `g.title <> ($1 || ' — maintenance')`, which decided by
|
|
138
|
+
// STRING which goals hold a version open: anyone able to name a goal could
|
|
139
|
+
// exempt it from the close gate. A boolean column cannot be spelled.
|
|
140
|
+
assert.equal(maintenanceGoalExemptSql('$1'), 'NOT g.is_maintenance');
|
|
141
|
+
assert.equal(/title/i.test(maintenanceGoalExemptSql('$1')), false,
|
|
142
|
+
'no title comparison may decide which goals hold a version open');
|
|
136
143
|
const dbv = src('modules/lifecycle/db-versions.js');
|
|
137
144
|
const uses = (dbv.match(/maintenanceGoalExemptSql\(/g) || []).length;
|
|
138
145
|
assert.equal(uses, 3, 'declaration + the open-goals read + the post-apply re-count, and nothing else');
|