@bongos/core 1.19.598 → 1.19.600
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 +27 -22
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +47 -66
- package/modules/lifecycle/db.js +0 -2
- package/modules/lifecycle/done-when.js +16 -2
- package/modules/lifecycle/version-autoclose.js +107 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +6 -1
- package/tests/goal_closure_invariants.mjs +7 -1
- package/tests/maintenance_goal_flag.mjs +71 -6
- package/tests/version_autoclose.mjs +170 -0
- package/tests/maintenance_carry_forward.mjs +0 -122
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.600",
|
|
6
|
+
"core_contract": "1.19.600",
|
|
7
|
+
"source_commit": "8e02f02563986161d1b77b266027bab29d5534aa",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-08T04:
|
|
9
|
+
"built_at": "2026-09-08T04:42:02.346Z",
|
|
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": 2075,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2556,
|
|
20
|
+
"tree_sha256": "a008ff8e9029ba8be4b741e6ce36012a90a005bad10e76f4fed030cee2404487",
|
|
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": "251ae2f82d65d325f220649d865bc5413a3febbf493db6994b6f3e9713968ac8"
|
|
2731
2731
|
},
|
|
2732
2732
|
{
|
|
2733
2733
|
"path": "docs/modules-contract.md",
|
|
@@ -5532,7 +5532,7 @@
|
|
|
5532
5532
|
{
|
|
5533
5533
|
"path": "modules/lifecycle/db-goals.js",
|
|
5534
5534
|
"mode": "0000644",
|
|
5535
|
-
"sha256": "
|
|
5535
|
+
"sha256": "dde23a909ea56f7102d42af9bd5a832449940b00b4bfac7ed779e7938d95885c"
|
|
5536
5536
|
},
|
|
5537
5537
|
{
|
|
5538
5538
|
"path": "modules/lifecycle/db-grade.js",
|
|
@@ -5572,7 +5572,7 @@
|
|
|
5572
5572
|
{
|
|
5573
5573
|
"path": "modules/lifecycle/db.js",
|
|
5574
5574
|
"mode": "0000644",
|
|
5575
|
-
"sha256": "
|
|
5575
|
+
"sha256": "33fc8959d522bcf18c445b1a5710940379f84d1e938eecdc52cdf52bec9c4b3d"
|
|
5576
5576
|
},
|
|
5577
5577
|
{
|
|
5578
5578
|
"path": "modules/lifecycle/dead-deps.js",
|
|
@@ -5582,7 +5582,7 @@
|
|
|
5582
5582
|
{
|
|
5583
5583
|
"path": "modules/lifecycle/done-when.js",
|
|
5584
5584
|
"mode": "0000644",
|
|
5585
|
-
"sha256": "
|
|
5585
|
+
"sha256": "7def4b53d251ecac2e9ce4c7aa2df8cb8814e2105d47417e5d43191884cd8ea1"
|
|
5586
5586
|
},
|
|
5587
5587
|
{
|
|
5588
5588
|
"path": "modules/lifecycle/est-advisory.js",
|
|
@@ -5869,6 +5869,11 @@
|
|
|
5869
5869
|
"mode": "0000644",
|
|
5870
5870
|
"sha256": "af00ee4629002c8df2448e2146a24108407a36b062859ee1afee293aa60cb209"
|
|
5871
5871
|
},
|
|
5872
|
+
{
|
|
5873
|
+
"path": "modules/lifecycle/version-autoclose.js",
|
|
5874
|
+
"mode": "0000644",
|
|
5875
|
+
"sha256": "06e035b8d1545d68a0e629e707ea7f86d48fac5dc8fe0c5e70ec8704508ecf7c"
|
|
5876
|
+
},
|
|
5872
5877
|
{
|
|
5873
5878
|
"path": "modules/lifecycle/version-close.js",
|
|
5874
5879
|
"mode": "0000644",
|
|
@@ -7622,12 +7627,12 @@
|
|
|
7622
7627
|
{
|
|
7623
7628
|
"path": "package-lock.json",
|
|
7624
7629
|
"mode": "0000644",
|
|
7625
|
-
"sha256": "
|
|
7630
|
+
"sha256": "d3456f52cc8555eb1a20f884dc2d32240032138193c5d525ea455d07423e9656"
|
|
7626
7631
|
},
|
|
7627
7632
|
{
|
|
7628
7633
|
"path": "package.json",
|
|
7629
7634
|
"mode": "0000644",
|
|
7630
|
-
"sha256": "
|
|
7635
|
+
"sha256": "107d899ff6ec92f0f2dd863f62ab2a68eaa82c7f60d9e172c243b9b5ff421f52"
|
|
7631
7636
|
},
|
|
7632
7637
|
{
|
|
7633
7638
|
"path": "public-docs/index.html",
|
|
@@ -9342,7 +9347,7 @@
|
|
|
9342
9347
|
{
|
|
9343
9348
|
"path": "src/module-api.js",
|
|
9344
9349
|
"mode": "0000644",
|
|
9345
|
-
"sha256": "
|
|
9350
|
+
"sha256": "fa5a67fefe06f235502a19ee017a75467ab1f5cadf77c2b004edd4109b397ece"
|
|
9346
9351
|
},
|
|
9347
9352
|
{
|
|
9348
9353
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9622,7 +9627,7 @@
|
|
|
9622
9627
|
{
|
|
9623
9628
|
"path": "tests/auto_satisfy_criteria.mjs",
|
|
9624
9629
|
"mode": "0000644",
|
|
9625
|
-
"sha256": "
|
|
9630
|
+
"sha256": "d7ac18d14e8e9e5e7b9e913a76953fe69bfefea4912e77ca30b285647bfcfca8"
|
|
9626
9631
|
},
|
|
9627
9632
|
{
|
|
9628
9633
|
"path": "tests/autonomous_fit_protected_paths.mjs",
|
|
@@ -10492,7 +10497,7 @@
|
|
|
10492
10497
|
{
|
|
10493
10498
|
"path": "tests/goal_closure_invariants.mjs",
|
|
10494
10499
|
"mode": "0000644",
|
|
10495
|
-
"sha256": "
|
|
10500
|
+
"sha256": "e5f937b9ce3264b6b4bb3a9ca57fa000b2190fe49c3ba6d70acdd9d26f55423c"
|
|
10496
10501
|
},
|
|
10497
10502
|
{
|
|
10498
10503
|
"path": "tests/goal_conflicts.mjs",
|
|
@@ -11289,11 +11294,6 @@
|
|
|
11289
11294
|
"mode": "0000644",
|
|
11290
11295
|
"sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
|
|
11291
11296
|
},
|
|
11292
|
-
{
|
|
11293
|
-
"path": "tests/maintenance_carry_forward.mjs",
|
|
11294
|
-
"mode": "0000644",
|
|
11295
|
-
"sha256": "4ac9164b84ad7bf8e9c1b45e7cb5e157762c992ddde16041410a528a534e60f5"
|
|
11296
|
-
},
|
|
11297
11297
|
{
|
|
11298
11298
|
"path": "tests/maintenance_goal.mjs",
|
|
11299
11299
|
"mode": "0000644",
|
|
@@ -11302,7 +11302,7 @@
|
|
|
11302
11302
|
{
|
|
11303
11303
|
"path": "tests/maintenance_goal_flag.mjs",
|
|
11304
11304
|
"mode": "0000644",
|
|
11305
|
-
"sha256": "
|
|
11305
|
+
"sha256": "d1f662d8533544ca1e7575f8b7c75fc93d89a4a60880bd47b873b068a3241e4b"
|
|
11306
11306
|
},
|
|
11307
11307
|
{
|
|
11308
11308
|
"path": "tests/manage_manifest_shared_read.mjs",
|
|
@@ -12689,6 +12689,11 @@
|
|
|
12689
12689
|
"mode": "0000644",
|
|
12690
12690
|
"sha256": "808a5fb2e14fb03ded6ed5ea0b3c0a0a7f53bfb885e853acb225f7b439a06ac3"
|
|
12691
12691
|
},
|
|
12692
|
+
{
|
|
12693
|
+
"path": "tests/version_autoclose.mjs",
|
|
12694
|
+
"mode": "0000644",
|
|
12695
|
+
"sha256": "3a9e1f7ae24aa4d25baaaff53890e4d963f662ae95e5cd69b00da1cd3f6cb012"
|
|
12696
|
+
},
|
|
12692
12697
|
{
|
|
12693
12698
|
"path": "tests/version_build_slot.mjs",
|
|
12694
12699
|
"mode": "0000644",
|
|
@@ -1645,5 +1645,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1645
1645
|
landed since 1.19.596 with no explicit bump. run 34186368179. (task 1002620)
|
|
1646
1646
|
1.19.598 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1647
1647
|
landed since 1.19.597 with no explicit bump. run 34186758247. (task 1002620)
|
|
1648
|
+
1.19.599 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1649
|
+
landed since 1.19.598 with no explicit bump. run 34187136306. (task 1002620)
|
|
1650
|
+
1.19.600 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1651
|
+
landed since 1.19.599 with no explicit bump. run 34187881031. (task 1002620)
|
|
1648
1652
|
---------------------------------------------------------------------------
|
|
1649
1653
|
```
|
|
@@ -17,6 +17,9 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
|
17
17
|
const { nonTerminalSql, isTerminalStatus } = require('./task-status');
|
|
18
18
|
const { maintenanceGoalTitle } = require('./goal-advisory.js');
|
|
19
19
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
20
|
+
// BV1.R16 (task 1003603) — auto-close, shared with the done-when cascade so the
|
|
21
|
+
// two achievement writers cannot drift apart (ADR 0263 §4).
|
|
22
|
+
const { autoCloseVersionsForGoals } = require('./version-autoclose.js');
|
|
20
23
|
|
|
21
24
|
// -------------------------------------------------------------------------
|
|
22
25
|
// Goals (ADR 0086 §2 / BV1.R56 — task 1510)
|
|
@@ -231,79 +234,45 @@ async function carryBugsForward({ fromVersionId, toVersionId }, deps = {}) {
|
|
|
231
234
|
);
|
|
232
235
|
if (!from[0]) return { fromGoalId: null, toGoalId: null, moved: 0 };
|
|
233
236
|
|
|
237
|
+
// RETURNS, NEVER THROWS (BV1.R18c, task 1003709). This used to throw
|
|
238
|
+
// CARRY_FORWARD_NO_DESTINATION here, and the call site is closeVersion's
|
|
239
|
+
// `deps.onClosed` — INSIDE the close transaction. A throw there rolls the whole
|
|
240
|
+
// close back, so a version whose successor had not been promoted yet would
|
|
241
|
+
// simply refuse to close. ADR 0263 §4 is explicit that this path may never fail
|
|
242
|
+
// the close, for the same reason closeCompletedWorkForShip may never fail a ship.
|
|
243
|
+
//
|
|
244
|
+
// The reason still travels, because a bare 0 is indistinguishable from "there
|
|
245
|
+
// was nothing to carry" — and the two want opposite responses: one is fine, the
|
|
246
|
+
// other means R20 ran the carry-over before the promotion (ADR 0263 §8's
|
|
247
|
+
// ordering trap) and the queue is still sitting on a shipped version.
|
|
234
248
|
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
|
-
}
|
|
249
|
+
if (!to) return { fromGoalId: String(from[0].id), toGoalId: null, moved: 0, reason: 'successor_not_building' };
|
|
241
250
|
|
|
242
251
|
// Only the UNFINISHED ones move. A shipped or abandoned defect is history and
|
|
243
252
|
// belongs to the version it was fixed in — dragging it forward would make every
|
|
244
253
|
// successor's maintenance goal a growing archive of everything ever reported.
|
|
254
|
+
// THE VERSION MOVES WITH THE GOAL (BV1.R18c, task 1003709). Setting only
|
|
255
|
+
// `goal_id` strands the task's `version_id` on the version being closed, and
|
|
256
|
+
// that column is not decoration: `version_progress` joins
|
|
257
|
+
// `tasks t ON t.version_id = v.id`, and `version-close.getCarryOverCount`
|
|
258
|
+
// filters on it directly. A half-moved task therefore counts against a SHIPPED
|
|
259
|
+
// version forever, never counts toward the successor, and leaves the closed
|
|
260
|
+
// version still reporting carry-over it no longer holds.
|
|
261
|
+
//
|
|
262
|
+
// Smoke rows are excluded the way version_progress excludes them — a test
|
|
263
|
+
// fixture must not ride across a version boundary into a real queue.
|
|
245
264
|
const { rows: moved } = await exec.query(
|
|
246
|
-
`UPDATE tasks SET goal_id = $2, updated_at = now()
|
|
265
|
+
`UPDATE tasks SET goal_id = $2, version_id = $3, updated_at = now()
|
|
247
266
|
WHERE goal_id = $1 AND ${nonTerminalSql('status')}
|
|
267
|
+
AND ${SMOKE_NOT_LIKE}
|
|
248
268
|
RETURNING id::text`,
|
|
249
|
-
[from[0].id, to.id]
|
|
269
|
+
[from[0].id, to.id, toVersionId]
|
|
250
270
|
);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 };
|
|
271
|
+
// The emptied source goal closes behind them: archived, not achieved — it was
|
|
272
|
+
// never "done", it stopped being the live queue. Leaving it open on a shipped
|
|
273
|
+
// version is the mirror of the defect this whole goal exists to remove.
|
|
274
|
+
await exec.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [from[0].id]);
|
|
275
|
+
return { fromGoalId: String(from[0].id), toGoalId: String(to.id), moved: moved.length, reason: null };
|
|
307
276
|
}
|
|
308
277
|
|
|
309
278
|
// listVersionAdmissions (BV1.R07, task 1003594) — every goal admitted into ONE
|
|
@@ -526,7 +495,20 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
|
526
495
|
// the goal simply stays open until its last task lands or is abandoned.
|
|
527
496
|
if (unfinishedTasks > 0) return { achieved: false, goal, unfinishedTasks };
|
|
528
497
|
const updated = await setGoalStatus({ goalId, status: 'achieved' }, deps);
|
|
529
|
-
|
|
498
|
+
|
|
499
|
+
// BV1.R16 (task 1003603, ADR 0263 §§3–4): the SECOND achievement writer. A goal
|
|
500
|
+
// reaching `achieved` may have been the version's last non-maintenance goal, and
|
|
501
|
+
// that is true whichever door it came through — this manual satisfy path or the
|
|
502
|
+
// ship-time cascade in done-when.js. ADR 0250 D2 already established that these
|
|
503
|
+
// two writers move together or a version closes correctly through one and wrongly
|
|
504
|
+
// through the other; the rule itself lives in ONE place (version-autoclose.js) so
|
|
505
|
+
// "together" is enforced by the require rather than by remembering.
|
|
506
|
+
//
|
|
507
|
+
// Runs on the caller's exec so it sees the flip above, which in the ship path is
|
|
508
|
+
// still uncommitted. Never throws: autoCloseVersionsForGoals catches per version.
|
|
509
|
+
const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
|
|
510
|
+
const versionsClosed = await autoCloseVersionsForGoals(exec, [updated]);
|
|
511
|
+
return { achieved: true, goal: updated, versionsClosed };
|
|
530
512
|
}
|
|
531
513
|
|
|
532
514
|
// addGoalMember — join. Upserts on the (goal_id, builder_id) PK so a re-join is
|
|
@@ -1228,7 +1210,6 @@ module.exports = {
|
|
|
1228
1210
|
carryBugsForward,
|
|
1229
1211
|
achieveGoalIfComplete,
|
|
1230
1212
|
archiveGoalWithDispositions,
|
|
1231
|
-
carryMaintenanceForward,
|
|
1232
1213
|
addGoalMember,
|
|
1233
1214
|
addGoalScopeModules,
|
|
1234
1215
|
createGoal,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -83,7 +83,6 @@ const {
|
|
|
83
83
|
listActiveClaimsForTasks,
|
|
84
84
|
listCriteriaForGoal,
|
|
85
85
|
archiveGoalWithDispositions,
|
|
86
|
-
carryMaintenanceForward,
|
|
87
86
|
openTasksInGoal,
|
|
88
87
|
listGoalMembers,
|
|
89
88
|
listGoals,
|
|
@@ -260,7 +259,6 @@ module.exports = {
|
|
|
260
259
|
listPendingRequestsForGoal,
|
|
261
260
|
listCriteriaForGoal,
|
|
262
261
|
archiveGoalWithDispositions,
|
|
263
|
-
carryMaintenanceForward,
|
|
264
262
|
openTasksInGoal,
|
|
265
263
|
createTask,
|
|
266
264
|
generalGoalIdForVersion,
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
const { pool } = require('../../src/module-api');
|
|
27
27
|
const { nonTerminalSql } = require('./task-status');
|
|
28
28
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
29
|
+
// BV1.R16 (task 1003603) — auto-close. Dependency-free module, so requiring it
|
|
30
|
+
// here creates no cycle (db-versions.js requires THIS file, which is why the
|
|
31
|
+
// rule could not live beside closeVersion).
|
|
32
|
+
const { autoCloseVersionsForGoals } = require('./version-autoclose.js');
|
|
29
33
|
|
|
30
34
|
// isPendingReview — the ONE derivation of the "met — pending review" auto-flag
|
|
31
35
|
// (ADR 0086 §6 / BV1.R63): a criterion with >=1 linked task, every one shipped,
|
|
@@ -474,7 +478,8 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null } = {}) {
|
|
|
474
478
|
// review off docs/architecture.md, whose tasks index list predated 228 and has
|
|
475
479
|
// been corrected.)
|
|
476
480
|
const goalIds = [...new Set(criteria.map((c) => c.goal_id).filter((g) => g != null))].map(Number);
|
|
477
|
-
|
|
481
|
+
// No goal achieved, so no version can have just become closable.
|
|
482
|
+
if (goalIds.length === 0) return { criteria, goals: [], versionsClosed: [] };
|
|
478
483
|
const { rows: goals } = await exec.query(
|
|
479
484
|
`UPDATE goals g
|
|
480
485
|
SET status = 'achieved', updated_at = now()
|
|
@@ -488,7 +493,16 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null } = {}) {
|
|
|
488
493
|
RETURNING g.id, g.title, g.version_id, g.status`,
|
|
489
494
|
[goalIds]
|
|
490
495
|
);
|
|
491
|
-
|
|
496
|
+
// BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
|
|
497
|
+
// non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
|
|
498
|
+
// above that have not committed yet — a check on the pool would never close the
|
|
499
|
+
// version on the ship that actually finished it.
|
|
500
|
+
//
|
|
501
|
+
// `autoCloseVersionsForGoals` catches per version and never rethrows: a version
|
|
502
|
+
// close must not be able to fail a builder's ship. The accepted failure is a
|
|
503
|
+
// version left un-closed, which the reconciler's next unscoped sweep heals.
|
|
504
|
+
const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
|
|
505
|
+
return { criteria, goals, versionsClosed };
|
|
492
506
|
}
|
|
493
507
|
|
|
494
508
|
// describeAutoClose — the one-line summary of what a close actually closed, or
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/version-autoclose.js — a version closes ITSELF when its last
|
|
4
|
+
// non-maintenance goal achieves (BV1.R16, task 1003603, goal 1000086,
|
|
5
|
+
// ADR 0250 D5, ADR 0263 §§3–4).
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS FILE EXISTS AT ALL, rather than living beside `closeVersion`.
|
|
8
|
+
// Goal achievement has TWO writers — `db-goals.achieveGoalIfComplete` (the manual
|
|
9
|
+
// `POST /done-when/:id/satisfy` path) and the `done-when.autoSatisfyShippedCriteria`
|
|
10
|
+
// cascade (every ship, every reconciler tick). Both must auto-close, and neither
|
|
11
|
+
// can reach `db-versions.js`: that file already requires `done-when`, so a call
|
|
12
|
+
// the other way is a require cycle. ADR 0263 §4 accepted writing the rule twice
|
|
13
|
+
// for exactly this reason.
|
|
14
|
+
//
|
|
15
|
+
// It is written ONCE instead, here, because this module requires NOTHING but
|
|
16
|
+
// `task-status` — the `task-status.js` precedent, which is dependency-free so a
|
|
17
|
+
// CLI can read it without paying for the pool. Both writers can require it, no
|
|
18
|
+
// cycle exists, and the rule that decides when a VERSION ends has one definition
|
|
19
|
+
// rather than two that must be kept in lockstep by hand. The duplicate the ADR
|
|
20
|
+
// permitted is a cost it did not have to pay.
|
|
21
|
+
//
|
|
22
|
+
// AUTO-CLOSE HAS NOTHING TO DISPOSITION, and that is the whole shape of this
|
|
23
|
+
// feature (ADR 0263 §3). It fires when the last non-maintenance goal achieves —
|
|
24
|
+
// so by construction every non-maintenance goal on the version is already
|
|
25
|
+
// `achieved` or `archived`, and the set needing a decision is EMPTY BY
|
|
26
|
+
// DEFINITION. The disposition map, the refusal that returns the goals, the
|
|
27
|
+
// successor lineage: all of that belongs to EARLY close (R12) and none of it can
|
|
28
|
+
// ever have input here. This file is therefore much smaller than ADR 0250's
|
|
29
|
+
// sentence makes it sound.
|
|
30
|
+
|
|
31
|
+
const { nonTerminalSql } = require('./task-status');
|
|
32
|
+
|
|
33
|
+
// The maintenance goal never holds a version open (ADR 0263 §7). Reads the
|
|
34
|
+
// `is_maintenance` FLAG that R18 (task 1003605, migration core_234) added — never
|
|
35
|
+
// a title, because deciding this by string would let anyone able to name a goal
|
|
36
|
+
// exempt it from the gate that ends a version.
|
|
37
|
+
const NOT_MAINTENANCE = 'NOT g.is_maintenance';
|
|
38
|
+
|
|
39
|
+
// autoCloseVersionIfComplete — flip `versionId` to `shipped` if, and only if, it
|
|
40
|
+
// holds no open non-maintenance goal.
|
|
41
|
+
//
|
|
42
|
+
// RUNS ON THE CALLER'S `exec`, which is the ship transaction's own client. That
|
|
43
|
+
// is load-bearing rather than tidy: the goal that just achieved was flipped in
|
|
44
|
+
// this same uncommitted transaction, so a check on the module pool would not see
|
|
45
|
+
// it and the version would never close on the ship that finished it — the same
|
|
46
|
+
// reason R09's closure check takes an exec.
|
|
47
|
+
//
|
|
48
|
+
// IDEMPOTENT BY `WHERE status = 'building'`. The reconciler's unscoped sweep runs
|
|
49
|
+
// this every tick, so the second and every later run must be a no-op; without the
|
|
50
|
+
// guard each sweep would rewrite `shipped_at` and the version's own record of
|
|
51
|
+
// when it finished would drift forward forever.
|
|
52
|
+
//
|
|
53
|
+
// Returns the closed version row, or null when the version is not ready (the
|
|
54
|
+
// overwhelmingly common answer) — null is not an error.
|
|
55
|
+
async function autoCloseVersionIfComplete(exec, versionId) {
|
|
56
|
+
if (!exec || versionId == null) return null;
|
|
57
|
+
const { rows } = await exec.query(
|
|
58
|
+
`UPDATE versions v
|
|
59
|
+
SET status = 'shipped', shipped_at = now()
|
|
60
|
+
WHERE v.id = $1
|
|
61
|
+
AND v.status = 'building'
|
|
62
|
+
AND NOT EXISTS (
|
|
63
|
+
SELECT 1 FROM goals g
|
|
64
|
+
WHERE g.version_id = v.id
|
|
65
|
+
AND g.status = 'open'
|
|
66
|
+
AND ${NOT_MAINTENANCE}
|
|
67
|
+
)
|
|
68
|
+
RETURNING v.id, v.name, v.track, v.status, v.shipped_at`,
|
|
69
|
+
[versionId]
|
|
70
|
+
);
|
|
71
|
+
return rows[0] ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// autoCloseVersionsForGoals — the call both achievement writers actually make.
|
|
75
|
+
//
|
|
76
|
+
// Takes the goals a cascade just achieved (each carrying `version_id`) and tries
|
|
77
|
+
// their versions, de-duplicated. A cascade can achieve several goals at once and
|
|
78
|
+
// they may sit on different versions, so this is a set rather than a single id.
|
|
79
|
+
//
|
|
80
|
+
// IT MAY NEVER FAIL A SHIP. This is not a preference — it is the established
|
|
81
|
+
// posture of `closeCompletedWorkForShip`, which swallows its own failure on the
|
|
82
|
+
// argument that a criterion close must never fail a ship and the reconciler's
|
|
83
|
+
// sweep retries every tick. A version close is a strictly WIDER write than a
|
|
84
|
+
// criterion close, so it inherits that posture a fortiori: the failure mode we
|
|
85
|
+
// accept is a version left UN-closed, which the next sweep heals, and the one we
|
|
86
|
+
// refuse is a builder's ship rolled back by a bug in version-close. Every throw
|
|
87
|
+
// is caught here, per version, so one bad version cannot stop another closing.
|
|
88
|
+
//
|
|
89
|
+
// Returns the rows that actually closed — usually empty.
|
|
90
|
+
async function autoCloseVersionsForGoals(exec, goals = [], onError = null) {
|
|
91
|
+
const ids = [...new Set((goals || []).map((g) => g && g.version_id).filter((v) => v != null))];
|
|
92
|
+
const closed = [];
|
|
93
|
+
for (const versionId of ids) {
|
|
94
|
+
try {
|
|
95
|
+
const row = await autoCloseVersionIfComplete(exec, versionId);
|
|
96
|
+
if (row) closed.push(row);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Reported, never propagated. A silent swallow would make a persistently
|
|
99
|
+
// broken close indistinguishable from "nothing was ready", which is the
|
|
100
|
+
// failure that hides for months.
|
|
101
|
+
if (typeof onError === 'function') onError(versionId, err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return closed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { autoCloseVersionIfComplete, autoCloseVersionsForGoals, NOT_MAINTENANCE };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.600",
|
|
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.600",
|
|
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.600",
|
|
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.600'; // 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');
|
|
@@ -107,7 +107,12 @@ await test('ASYMMETRY (rollup): the /status rollup is untouched by this task', (
|
|
|
107
107
|
await test('EXEC: every write goes through the caller-supplied exec (so a ship txn is atomic)', async () => {
|
|
108
108
|
const exec = fakeExec({ rows: [CRIT] }, { rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] });
|
|
109
109
|
const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|
|
110
|
-
|
|
110
|
+
// Three since BV1.R16 (task 1003603): the criterion update, the goal cascade,
|
|
111
|
+
// and the version auto-close — which is the point, because the third must ride
|
|
112
|
+
// the SAME exec as the first two or a version would never close on the ship
|
|
113
|
+
// that finished it.
|
|
114
|
+
assert.equal(exec.calls.length, 3, 'criterion update + goal cascade + version auto-close');
|
|
115
|
+
assert.match(exec.calls[2].sql, /UPDATE versions v/, 'and the third is the auto-close, on exec');
|
|
111
116
|
assert.deepEqual(out.criteria, [CRIT]);
|
|
112
117
|
assert.deepEqual(out.goals.map((g) => g.id), [77]);
|
|
113
118
|
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
@@ -251,9 +251,15 @@ test('the cascade reads through the exec it was handed, so it sees the ship\'s o
|
|
|
251
251
|
test('every statement goes through the caller exec — none reaches the module pool', async () => {
|
|
252
252
|
const exec = makeExec(world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED()] }));
|
|
253
253
|
await autoSatisfyShippedCriteria(exec, { taskId: 200 });
|
|
254
|
-
|
|
254
|
+
// BV1.R16 (task 1003603) added a third: the version auto-close. It belongs in
|
|
255
|
+
// THIS test rather than only in its own, because the property under assertion —
|
|
256
|
+
// nothing reaches the module pool — is exactly what makes auto-close correct on
|
|
257
|
+
// a ship: the goal it keys on was flipped in this uncommitted transaction, and
|
|
258
|
+
// a pool read would not see it.
|
|
259
|
+
assert.equal(exec.calls.length, 3, 'the criterion close, the cascade and the version auto-close, all on exec');
|
|
255
260
|
assert.match(exec.calls[0].sql, /UPDATE done_when_criteria/);
|
|
256
261
|
assert.match(exec.calls[1].sql, /UPDATE goals g/);
|
|
262
|
+
assert.match(exec.calls[2].sql, /UPDATE versions v/);
|
|
257
263
|
});
|
|
258
264
|
|
|
259
265
|
// ---------------------------------------------------------------------------
|
|
@@ -117,7 +117,7 @@ test('carryBugsForward moves only NON-TERMINAL tasks', async () => {
|
|
|
117
117
|
return { rows: [] };
|
|
118
118
|
});
|
|
119
119
|
const out = await carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client });
|
|
120
|
-
assert.deepEqual(out, { fromGoalId: '1', toGoalId: '5', moved: 2 });
|
|
120
|
+
assert.deepEqual(out, { fromGoalId: '1', toGoalId: '5', moved: 2, reason: null });
|
|
121
121
|
const move = r.seen.find((q) => /UPDATE tasks/.test(q.sql));
|
|
122
122
|
assert.match(move.sql, /status NOT IN \('shipped', 'abandoned'\)/,
|
|
123
123
|
'a fixed or cut defect belongs to the version it was resolved in — carrying it forward would make every successor an archive');
|
|
@@ -130,20 +130,33 @@ test('a version with no maintenance goal carries nothing, and that is not an err
|
|
|
130
130
|
assert.equal(r.seen.some((q) => /UPDATE tasks/.test(q.sql)), false, 'nothing is written');
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
test('carrying into a version that is not building
|
|
133
|
+
test('carrying into a version that is not building REPORTS loudly — without failing the close', async () => {
|
|
134
134
|
// ADR 0263 §8: promotion must happen BEFORE the carry-over, because
|
|
135
135
|
// ensureMaintenanceGoal returns null for a version that is not `building`. A
|
|
136
136
|
// silent no-op here would strand the bug queue on a shipped version — the
|
|
137
137
|
// exact entombment this whole path exists to end — and look like success.
|
|
138
|
+
//
|
|
139
|
+
// BV1.R18c (task 1003709) changed HOW it is loud, not whether. This threw
|
|
140
|
+
// CARRY_FORWARD_NO_DESTINATION, and the call site is closeVersion's
|
|
141
|
+
// `deps.onClosed` — inside the close transaction. A throw there rolls the whole
|
|
142
|
+
// close back, so a version whose successor had not been promoted yet would
|
|
143
|
+
// refuse to close at all, and ADR 0263 §4 says this path may never fail the
|
|
144
|
+
// close (the same posture closeCompletedWorkForShip has toward a ship).
|
|
145
|
+
//
|
|
146
|
+
// The distinction the throw was protecting is kept in the RETURN: `reason` tells
|
|
147
|
+
// a caller that the destination was missing, which a bare `moved: 0` could not —
|
|
148
|
+
// and that is the whole point, because "nothing to carry" and "you ran this
|
|
149
|
+
// before the promotion" want opposite responses.
|
|
138
150
|
const r = recorder((sql) => {
|
|
139
151
|
if (/SELECT id FROM goals/.test(sql)) return { rows: [{ id: 1 }] };
|
|
140
152
|
if (/FROM versions/.test(sql)) return { rows: [{ status: 'planning' }] };
|
|
141
153
|
return { rows: [] };
|
|
142
154
|
});
|
|
143
|
-
await
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
);
|
|
155
|
+
const out = await carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client });
|
|
156
|
+
assert.equal(out.moved, 0);
|
|
157
|
+
assert.equal(out.reason, 'successor_not_building');
|
|
158
|
+
assert.equal(out.toGoalId, null, 'and it names no destination, because there was none');
|
|
159
|
+
assert.equal(r.seen.some((q) => /UPDATE tasks/.test(q.sql)), false, 'nothing moved');
|
|
147
160
|
});
|
|
148
161
|
|
|
149
162
|
test('carryBugsForward does NOT refuse an actively-claimed task', () => {
|
|
@@ -221,3 +234,55 @@ test('the find-or-create survives a pre-existing duplicate rather than throwing'
|
|
|
221
234
|
assert.match(fn.slice(0, 2500), /ORDER BY id LIMIT 1/,
|
|
222
235
|
'with no unique constraint, a duplicate must be survivable and the answer stable');
|
|
223
236
|
});
|
|
237
|
+
|
|
238
|
+
// ---- BV1.R18c (task 1003709): the two defects the merge closed -------------
|
|
239
|
+
//
|
|
240
|
+
// `main` briefly carried TWO carry-forward functions, one per session that built
|
|
241
|
+
// R18. Collapsing them was not a coin toss: each had something the other needed,
|
|
242
|
+
// and the two below are the halves that were missing here.
|
|
243
|
+
|
|
244
|
+
test('R18c: the VERSION moves with the goal, or the task counts against a shipped version forever', async () => {
|
|
245
|
+
// `version_progress` joins `tasks t ON t.version_id = v.id` (verified against the
|
|
246
|
+
// live view), and `version-close.getCarryOverCount` filters on it directly. A task
|
|
247
|
+
// moved to the successor's goal but left stamped with the closing version counts
|
|
248
|
+
// against a SHIPPED version, never counts toward the successor, and leaves the
|
|
249
|
+
// closed version still reporting carry-over it no longer holds.
|
|
250
|
+
const r = recorder((sql) => {
|
|
251
|
+
if (/SELECT id FROM goals/.test(sql)) return { rows: [{ id: 1 }] };
|
|
252
|
+
if (/FROM versions/.test(sql)) return { rows: [{ status: 'building' }] };
|
|
253
|
+
if (/pg_advisory_xact_lock/.test(sql)) return { rows: [] };
|
|
254
|
+
if (/SELECT id, version_id/.test(sql)) return { rows: [{ id: 5 }] };
|
|
255
|
+
if (/UPDATE tasks/.test(sql)) return { rows: [{ id: '11' }] };
|
|
256
|
+
return { rows: [] };
|
|
257
|
+
});
|
|
258
|
+
await carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client });
|
|
259
|
+
const mv = r.seen.find((q) => /UPDATE tasks/.test(q.sql));
|
|
260
|
+
assert.ok(mv, 'the move must happen');
|
|
261
|
+
assert.match(mv.sql, /version_id = \$3/, 'goal_id alone leaves the task half-moved');
|
|
262
|
+
assert.equal(mv.params[2], 'V2');
|
|
263
|
+
assert.match(mv.sql, /NOT LIKE/, 'and a smoke fixture must not ride across a version boundary');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('R18c: a successor that is not yet building RETURNS, it does not throw', async () => {
|
|
267
|
+
// The call site is closeVersion's `deps.onClosed` — inside the close
|
|
268
|
+
// transaction. A throw there rolls the whole close back, so a version whose
|
|
269
|
+
// successor had not been promoted would refuse to close. ADR 0263 §4 says this
|
|
270
|
+
// path may never fail the close.
|
|
271
|
+
const r = recorder((sql) => {
|
|
272
|
+
if (/SELECT id FROM goals/.test(sql)) return { rows: [{ id: 1 }] };
|
|
273
|
+
if (/FROM versions/.test(sql)) return { rows: [{ status: 'planning' }] };
|
|
274
|
+
return { rows: [] };
|
|
275
|
+
});
|
|
276
|
+
const out = await carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client });
|
|
277
|
+
assert.equal(out.moved, 0);
|
|
278
|
+
assert.equal(out.reason, 'successor_not_building',
|
|
279
|
+
'a bare 0 is indistinguishable from "nothing to carry", and the two want opposite responses');
|
|
280
|
+
assert.equal(out.toGoalId, null);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('R18c: exactly ONE carry-forward is exported', () => {
|
|
284
|
+
const db = require('../modules/lifecycle/db.js');
|
|
285
|
+
assert.equal(typeof db.carryBugsForward, 'function');
|
|
286
|
+
assert.equal(db.carryMaintenanceForward, undefined,
|
|
287
|
+
'two functions for one job is how a call site picks the wrong one');
|
|
288
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// tests/version_autoclose.mjs — a version closes ITSELF when its last
|
|
2
|
+
// non-maintenance goal achieves (BV1.R16, task 1003603, goal 1000086,
|
|
3
|
+
// ADR 0250 D5, ADR 0263 §§3–4).
|
|
4
|
+
//
|
|
5
|
+
// THE SHAPE, which is smaller than ADR 0250's sentence suggests. Auto-close fires
|
|
6
|
+
// when the last non-maintenance goal achieves — so by construction every
|
|
7
|
+
// non-maintenance goal on the version is already closed, and the set needing a
|
|
8
|
+
// disposition is EMPTY BY DEFINITION. The disposition map, the refusal that
|
|
9
|
+
// returns the goals, the successor lineage: all of that is EARLY close (R12) and
|
|
10
|
+
// none of it can ever have input here.
|
|
11
|
+
//
|
|
12
|
+
// THE TWO PROPERTIES THAT ARE LOAD-BEARING, and the reason this file is not just
|
|
13
|
+
// a happy-path check:
|
|
14
|
+
//
|
|
15
|
+
// 1. It hangs off BOTH achievement writers. `db-goals.achieveGoalIfComplete` is
|
|
16
|
+
// the manual satisfy path; `done-when.autoSatisfyShippedCriteria` is the
|
|
17
|
+
// cascade on every ship and every reconciler tick. ADR 0250 D2 established
|
|
18
|
+
// that these two move together or a version closes correctly through one door
|
|
19
|
+
// and wrongly through the other. ADR 0263 §4 expected the rule to be written
|
|
20
|
+
// TWICE for that reason — a require cycle stops db-versions.js being the
|
|
21
|
+
// shared home. It is written ONCE instead, in a dependency-free module both
|
|
22
|
+
// can require, so "together" is enforced by the import rather than by anyone
|
|
23
|
+
// remembering.
|
|
24
|
+
//
|
|
25
|
+
// 2. It may NEVER fail a ship. A version close is a strictly wider write than a
|
|
26
|
+
// criterion close, and `closeCompletedWorkForShip` already swallows its own
|
|
27
|
+
// failure on the argument that a criterion close must not fail a ship. The
|
|
28
|
+
// accepted failure here is a version left UN-closed, which the next sweep
|
|
29
|
+
// heals; the refused one is a builder's ship rolled back by a bug in
|
|
30
|
+
// version-close.
|
|
31
|
+
//
|
|
32
|
+
// Run: node --test tests/version_autoclose.mjs
|
|
33
|
+
|
|
34
|
+
import assert from 'node:assert/strict';
|
|
35
|
+
import { test } from 'node:test';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
|
|
39
|
+
process.env.NODE_ENV = 'test';
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const { autoCloseVersionIfComplete, autoCloseVersionsForGoals, NOT_MAINTENANCE } =
|
|
42
|
+
require('../modules/lifecycle/version-autoclose.js');
|
|
43
|
+
|
|
44
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
45
|
+
const AUTOCLOSE = src('modules/lifecycle/version-autoclose.js');
|
|
46
|
+
const DONE_WHEN = src('modules/lifecycle/done-when.js');
|
|
47
|
+
const DB_GOALS = src('modules/lifecycle/db-goals.js');
|
|
48
|
+
|
|
49
|
+
const recorder = (handler) => {
|
|
50
|
+
const seen = [];
|
|
51
|
+
return { seen, exec: { query: async (sql, params) => { seen.push({ sql, params }); return handler(sql, params); } } };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// The predicate
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
test('the maintenance goal never holds a version open, and it is read by FLAG', () => {
|
|
59
|
+
assert.equal(NOT_MAINTENANCE, 'NOT g.is_maintenance');
|
|
60
|
+
assert.equal(/title/i.test(NOT_MAINTENANCE), false,
|
|
61
|
+
'deciding this by string would let anyone able to name a goal exempt it from the gate that ends a version');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the close asks "is any non-maintenance goal still open", and nothing else', async () => {
|
|
65
|
+
const r = recorder(() => ({ rows: [] }));
|
|
66
|
+
await autoCloseVersionIfComplete(r.exec, 'V1');
|
|
67
|
+
const sql = r.seen[0].sql.replace(/\s+/g, ' ');
|
|
68
|
+
assert.match(sql, /UPDATE versions v/);
|
|
69
|
+
assert.match(sql, /NOT EXISTS \( SELECT 1 FROM goals g/);
|
|
70
|
+
assert.match(sql, /g\.status = 'open'/);
|
|
71
|
+
assert.match(sql, /NOT g\.is_maintenance/);
|
|
72
|
+
// No disposition machinery may appear here — see the header.
|
|
73
|
+
assert.equal(/disposition|abandon|roll_forward/i.test(sql), false,
|
|
74
|
+
'auto-close has nothing to disposition; that is all early close');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the flip is guarded on building, so a reconciler retry is a no-op', async () => {
|
|
78
|
+
// Without the guard every sweep rewrites shipped_at and the version's own
|
|
79
|
+
// record of when it finished drifts forward forever.
|
|
80
|
+
const r = recorder(() => ({ rows: [] }));
|
|
81
|
+
await autoCloseVersionIfComplete(r.exec, 'V1');
|
|
82
|
+
assert.match(r.seen[0].sql.replace(/\s+/g, ' '), /v\.status = 'building'/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a version that is not ready returns null, which is not an error', async () => {
|
|
86
|
+
const r = recorder(() => ({ rows: [] }));
|
|
87
|
+
assert.equal(await autoCloseVersionIfComplete(r.exec, 'V1'), null);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a missing exec or version id is a no-op, not a throw', async () => {
|
|
91
|
+
assert.equal(await autoCloseVersionIfComplete(null, 'V1'), null);
|
|
92
|
+
const r = recorder(() => ({ rows: [] }));
|
|
93
|
+
assert.equal(await autoCloseVersionIfComplete(r.exec, null), null);
|
|
94
|
+
assert.equal(r.seen.length, 0, 'and it must not issue a query it cannot key');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// It may never fail a ship
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
test('a throwing close is CAUGHT — a ship must never be rolled back by version-close', async () => {
|
|
102
|
+
const boom = { query: async () => { throw new Error('deadlock detected'); } };
|
|
103
|
+
const seen = [];
|
|
104
|
+
const out = await autoCloseVersionsForGoals(boom, [{ version_id: 'V1' }], (v, e) => seen.push(`${v}:${e.message}`));
|
|
105
|
+
assert.deepEqual(out, []);
|
|
106
|
+
assert.deepEqual(seen, ['V1:deadlock detected'],
|
|
107
|
+
'reported, never propagated — a silent swallow makes a persistently broken close look like "nothing was ready"');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('one bad version does not stop another closing', async () => {
|
|
111
|
+
let n = 0;
|
|
112
|
+
const exec = { query: async () => { n += 1; if (n === 1) throw new Error('nope'); return { rows: [{ id: 'V2', status: 'shipped' }] }; } };
|
|
113
|
+
const out = await autoCloseVersionsForGoals(exec, [{ version_id: 'V1' }, { version_id: 'V2' }], () => {});
|
|
114
|
+
assert.deepEqual(out.map((v) => v.id), ['V2']);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('versions are de-duplicated — a cascade can achieve several goals on one version', async () => {
|
|
118
|
+
const r = recorder(() => ({ rows: [] }));
|
|
119
|
+
await autoCloseVersionsForGoals(r.exec, [{ version_id: 'V1' }, { version_id: 'V1' }, { version_id: 'V2' }]);
|
|
120
|
+
assert.deepEqual(r.seen.map((q) => q.params[0]), ['V1', 'V2']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a goal with no version_id is skipped rather than queried as null', async () => {
|
|
124
|
+
const r = recorder(() => ({ rows: [] }));
|
|
125
|
+
assert.deepEqual(await autoCloseVersionsForGoals(r.exec, [{}, null, undefined]), []);
|
|
126
|
+
assert.equal(r.seen.length, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// BOTH achievement writers, and the reason it is written once
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
test('the ship-time cascade auto-closes, on the caller exec', () => {
|
|
134
|
+
assert.match(DONE_WHEN, /const versionsClosed = await autoCloseVersionsForGoals\(exec, goals\)/,
|
|
135
|
+
'on `exec`, not the pool — the goal it just achieved is uncommitted, and a pool read would never close the version on the ship that finished it');
|
|
136
|
+
assert.match(DONE_WHEN, /return \{ criteria, goals, versionsClosed \}/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the MANUAL satisfy path auto-closes too', () => {
|
|
140
|
+
// The failure this prevents: a version that closes correctly through a ship and
|
|
141
|
+
// wrongly through POST /done-when/:id/satisfy.
|
|
142
|
+
assert.match(DB_GOALS, /const versionsClosed = await autoCloseVersionsForGoals\(exec, \[updated\]\)/);
|
|
143
|
+
assert.match(DB_GOALS, /return \{ achieved: true, goal: updated, versionsClosed \}/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('both writers import the SAME rule — it is not written twice', () => {
|
|
147
|
+
// ADR 0263 §4 expected a deliberate duplicate, because db-versions.js requires
|
|
148
|
+
// done-when.js and so cannot be the shared home. A dependency-free module can
|
|
149
|
+
// be, which makes "the two copies move together" an import rather than a habit.
|
|
150
|
+
for (const [name, code] of [['done-when.js', DONE_WHEN], ['db-goals.js', DB_GOALS]]) {
|
|
151
|
+
assert.match(code, /require\('\.\/version-autoclose\.js'\)/, `${name} must import the shared rule`);
|
|
152
|
+
}
|
|
153
|
+
// And the rule's SQL exists in exactly one file.
|
|
154
|
+
assert.equal(/UPDATE versions v[\s\S]*?NOT EXISTS/.test(DONE_WHEN), false, 'done-when.js must not carry its own copy');
|
|
155
|
+
assert.equal(/UPDATE versions v[\s\S]*?NOT EXISTS/.test(DB_GOALS), false, 'db-goals.js must not carry its own copy');
|
|
156
|
+
assert.match(AUTOCLOSE, /UPDATE versions v/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the shared module stays dependency-free, or the cycle comes back', () => {
|
|
160
|
+
// db-versions.js requires done-when.js. If this module ever requires either,
|
|
161
|
+
// the import that makes the single definition possible becomes a cycle.
|
|
162
|
+
const requires = [...AUTOCLOSE.matchAll(/require\('([^']+)'\)/g)].map((m) => m[1]);
|
|
163
|
+
assert.deepEqual(requires, ['./task-status'],
|
|
164
|
+
'only task-status — the dependency-free precedent that lets both writers share this');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('an empty cascade short-circuits without asking about versions', () => {
|
|
168
|
+
assert.match(DONE_WHEN, /if \(goalIds\.length === 0\) return \{ criteria, goals: \[\], versionsClosed: \[\] \}/,
|
|
169
|
+
'no goal achieved means no version can have just become closable');
|
|
170
|
+
});
|
|
@@ -1,122 +0,0 @@
|
|
|
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();
|