@bongos/core 1.19.599 → 1.19.601
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 +35 -20
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +6 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +17 -1
- package/modules/lifecycle/db-versions.js +42 -4
- package/modules/lifecycle/db.js +2 -1
- package/modules/lifecycle/done-when.js +16 -2
- package/modules/lifecycle/routes/versions.js +42 -1
- 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/version_autoclose.mjs +170 -0
- package/tests/version_promotion.mjs +143 -0
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.601",
|
|
6
|
+
"core_contract": "1.19.601",
|
|
7
|
+
"source_commit": "16a19ddf0ad7e10177aea562deb878093568f308",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-08T05:00:27.028Z",
|
|
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": 2076,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2557,
|
|
20
|
+
"tree_sha256": "eb65ec1fb208a9e7c09906234b5a43e7660816544a9b4ae44ecf94d7f64e6260",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -392,7 +392,7 @@
|
|
|
392
392
|
{
|
|
393
393
|
"path": "clients/bongos-client/index.d.ts",
|
|
394
394
|
"mode": "0000644",
|
|
395
|
-
"sha256": "
|
|
395
|
+
"sha256": "877ca14cd017c1392fcc5206c2f3603fb24becdc32670aa1e2ad0de9d930dfca"
|
|
396
396
|
},
|
|
397
397
|
{
|
|
398
398
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1852,7 +1852,7 @@
|
|
|
1852
1852
|
{
|
|
1853
1853
|
"path": "docs/api/openapi.json",
|
|
1854
1854
|
"mode": "0000644",
|
|
1855
|
-
"sha256": "
|
|
1855
|
+
"sha256": "81485c0440de636511dc9ee723e18af89f313b175ea626723d0beeeed31bba2f"
|
|
1856
1856
|
},
|
|
1857
1857
|
{
|
|
1858
1858
|
"path": "docs/architecture.md",
|
|
@@ -2727,7 +2727,7 @@
|
|
|
2727
2727
|
{
|
|
2728
2728
|
"path": "docs/module-api-changelog.md",
|
|
2729
2729
|
"mode": "0000644",
|
|
2730
|
-
"sha256": "
|
|
2730
|
+
"sha256": "71f84a8b41b3efb16f49909dcf6b6542deecfa51378e24ad271267cf3de1d6a9"
|
|
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",
|
|
@@ -5567,12 +5567,12 @@
|
|
|
5567
5567
|
{
|
|
5568
5568
|
"path": "modules/lifecycle/db-versions.js",
|
|
5569
5569
|
"mode": "0000644",
|
|
5570
|
-
"sha256": "
|
|
5570
|
+
"sha256": "2c10a17f86506788ab2f9db25c23024120ec9fddb3c7c4894e65d8354cd46869"
|
|
5571
5571
|
},
|
|
5572
5572
|
{
|
|
5573
5573
|
"path": "modules/lifecycle/db.js",
|
|
5574
5574
|
"mode": "0000644",
|
|
5575
|
-
"sha256": "
|
|
5575
|
+
"sha256": "b4b2d6d7890ec67e52fc00718a814bf01a64f4432ed7380a175dca8bc794b0e4"
|
|
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",
|
|
@@ -5817,7 +5817,7 @@
|
|
|
5817
5817
|
{
|
|
5818
5818
|
"path": "modules/lifecycle/routes/versions.js",
|
|
5819
5819
|
"mode": "0000644",
|
|
5820
|
-
"sha256": "
|
|
5820
|
+
"sha256": "014de34389dbf26b4b089b677466c9c7cfd81f79b9e1714241be12f589086c0f"
|
|
5821
5821
|
},
|
|
5822
5822
|
{
|
|
5823
5823
|
"path": "modules/lifecycle/routes/visuals.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": "d9ab931e2e3779b181225611efe7019b45d037bea4249d643574104445931164"
|
|
7626
7631
|
},
|
|
7627
7632
|
{
|
|
7628
7633
|
"path": "package.json",
|
|
7629
7634
|
"mode": "0000644",
|
|
7630
|
-
"sha256": "
|
|
7635
|
+
"sha256": "9903e5cd3c9d790a76647c7fff7556c33d900dd119d5b5afd0ada6797508df1d"
|
|
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": "de0d452d14fef52118d0302145065609722f6fbdf333fcbf47ec5864cbd3d86a"
|
|
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",
|
|
@@ -12684,6 +12689,11 @@
|
|
|
12684
12689
|
"mode": "0000644",
|
|
12685
12690
|
"sha256": "808a5fb2e14fb03ded6ed5ea0b3c0a0a7f53bfb885e853acb225f7b439a06ac3"
|
|
12686
12691
|
},
|
|
12692
|
+
{
|
|
12693
|
+
"path": "tests/version_autoclose.mjs",
|
|
12694
|
+
"mode": "0000644",
|
|
12695
|
+
"sha256": "3a9e1f7ae24aa4d25baaaff53890e4d963f662ae95e5cd69b00da1cd3f6cb012"
|
|
12696
|
+
},
|
|
12687
12697
|
{
|
|
12688
12698
|
"path": "tests/version_build_slot.mjs",
|
|
12689
12699
|
"mode": "0000644",
|
|
@@ -12709,6 +12719,11 @@
|
|
|
12709
12719
|
"mode": "0000644",
|
|
12710
12720
|
"sha256": "692261c26e3da46519757733a7fcf9a9998e0942bc5890688b66b79590d82c27"
|
|
12711
12721
|
},
|
|
12722
|
+
{
|
|
12723
|
+
"path": "tests/version_promotion.mjs",
|
|
12724
|
+
"mode": "0000644",
|
|
12725
|
+
"sha256": "43ba25381f3d02d7ecaaa8a2639bb8f1f72587d6966e6498db1c887b289148eb"
|
|
12726
|
+
},
|
|
12712
12727
|
{
|
|
12713
12728
|
"path": "tests/version_roll_forward.mjs",
|
|
12714
12729
|
"mode": "0000644",
|
|
@@ -390,7 +390,7 @@ export interface PostTasksNewcomerRestockResponse { ok: boolean; stamped: unknow
|
|
|
390
390
|
export interface PostTasksRequest { version_id: string; title: string; description?: string; status?: string; touches?: string[]; est_minutes?: number; est_cost_usd?: number; manual_degree?: number; priority?: number; automation_tag?: string; credits_reward?: number; source?: string; source_ref?: string; value_summary?: string; kind?: string; discipline?: string; parallel_safe?: unknown; newcomer_friendly?: boolean; security_sensitive?: boolean; requires_rank?: string; auto_detect_touches?: boolean; criterion_ids?: unknown[]; module_key?: string; goal_id?: number }
|
|
391
391
|
export interface PostTasksResponse { task: unknown; needs_migration: unknown; criteria: unknown; idempotent: boolean }
|
|
392
392
|
export interface PostVersionsIdCloseRequest { reason?: string; dispositions?: Record<string, unknown> }
|
|
393
|
-
export interface PostVersionsIdCloseResponse { ok: boolean; version: unknown; applied: unknown }
|
|
393
|
+
export interface PostVersionsIdCloseResponse { ok: boolean; version: unknown; applied: unknown; promoted: unknown; carried: unknown }
|
|
394
394
|
export interface PostVersionsIdDoneWhenRequest { criterion_id: string; criterion_md: string; goal_id?: number; sort_order?: number }
|
|
395
395
|
export interface PostVersionsIdDoneWhenResponse { ok: boolean; criterion: unknown }
|
|
396
396
|
export interface PostVersionsRequest { id: string; name: string; status?: string; track?: string; done_when?: string; scope_doc_path?: string; criteria?: unknown[] }
|
package/docs/api/openapi.json
CHANGED
|
@@ -22494,12 +22494,16 @@
|
|
|
22494
22494
|
"type": "boolean"
|
|
22495
22495
|
},
|
|
22496
22496
|
"version": {},
|
|
22497
|
-
"applied": {}
|
|
22497
|
+
"applied": {},
|
|
22498
|
+
"promoted": {},
|
|
22499
|
+
"carried": {}
|
|
22498
22500
|
},
|
|
22499
22501
|
"required": [
|
|
22500
22502
|
"ok",
|
|
22501
22503
|
"version",
|
|
22502
|
-
"applied"
|
|
22504
|
+
"applied",
|
|
22505
|
+
"promoted",
|
|
22506
|
+
"carried"
|
|
22503
22507
|
]
|
|
22504
22508
|
},
|
|
22505
22509
|
"PostVersionsIdDoneWhenRequest": {
|
|
@@ -1647,5 +1647,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1647
1647
|
landed since 1.19.597 with no explicit bump. run 34186758247. (task 1002620)
|
|
1648
1648
|
1.19.599 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1649
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)
|
|
1652
|
+
1.19.601 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1653
|
+
landed since 1.19.600 with no explicit bump. run 34188958832. (task 1002620)
|
|
1650
1654
|
---------------------------------------------------------------------------
|
|
1651
1655
|
```
|
|
@@ -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)
|
|
@@ -492,7 +495,20 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
|
492
495
|
// the goal simply stays open until its last task lands or is abandoned.
|
|
493
496
|
if (unfinishedTasks > 0) return { achieved: false, goal, unfinishedTasks };
|
|
494
497
|
const updated = await setGoalStatus({ goalId, status: 'achieved' }, deps);
|
|
495
|
-
|
|
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 };
|
|
496
512
|
}
|
|
497
513
|
|
|
498
514
|
// addGoalMember — join. Upserts on the (goal_id, builder_id) PK so a re-join is
|
|
@@ -245,14 +245,51 @@ async function openNonMaintenanceGoals(versionId, deps = {}) {
|
|
|
245
245
|
// must run INSIDE this transaction and BEFORE any carry-over, because
|
|
246
246
|
// `ensureMaintenanceGoal` returns null for a non-`building` version. The seam
|
|
247
247
|
// is `deps.onClosed` below rather than a later edit to this function.
|
|
248
|
-
// • It
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
248
|
+
// • It DOES create successor goals now — task 1003604 (R17) shipped, so
|
|
249
|
+
// `roll_forward` writes the successor, moves the unfinished tasks and records
|
|
250
|
+
// `succeeded_by_goal_id`. This note used to say the opposite; it is kept,
|
|
251
|
+
// corrected, because a reader who met the old wording would otherwise go
|
|
252
|
+
// looking for the abstention it described.
|
|
252
253
|
// • It does not write `limitations/<version>-shipped.md`. That is a repo file; a
|
|
253
254
|
// route cannot write one, and file I/O has no business in this transaction.
|
|
254
255
|
//
|
|
255
256
|
// `plan` is planVersionClose's validated output: [{ goalId, verb }].
|
|
257
|
+
// promotePlanningVersion — the single `planning` version becomes the live train
|
|
258
|
+
// (BV1.R20, task 1003607, goal 1000086, ADR 0263 §8).
|
|
259
|
+
//
|
|
260
|
+
// WHY IT RUNS IN THE CLOSE'S TRANSACTION. A project with zero building versions
|
|
261
|
+
// is a BROKEN state, not an intermediate one: `currentBuildingVersionId` returns
|
|
262
|
+
// null there and every routing path closed over it fails, so nothing is
|
|
263
|
+
// claimable. Promoting in a second transaction would leave exactly that window
|
|
264
|
+
// open, however briefly.
|
|
265
|
+
//
|
|
266
|
+
// ZERO PLANNING VERSIONS IS NOT AN ERROR. The close succeeds and the project sits
|
|
267
|
+
// with no live train until someone cuts one. Refusing the close instead would
|
|
268
|
+
// trap a finished version open because nobody had scoped the next one yet — and
|
|
269
|
+
// the close is the thing that makes scoping the next one worth doing.
|
|
270
|
+
//
|
|
271
|
+
// SELF-GUARDING IN SQL rather than by a read-then-write. The `NOT EXISTS`
|
|
272
|
+
// sub-select is what keeps this from creating the second building version R19
|
|
273
|
+
// forbids: it is evaluated inside the same statement, so a concurrent close
|
|
274
|
+
// cannot slip a promotion past a check that has already run. `ORDER BY id LIMIT 1`
|
|
275
|
+
// makes the pick deterministic; R04 guarantees there is at most one to pick.
|
|
276
|
+
//
|
|
277
|
+
// `started_at` is stamped only if it was never set — a version's start is when it
|
|
278
|
+
// began building, and a re-promotion (which cannot happen, but the COALESCE costs
|
|
279
|
+
// nothing) must not rewrite history.
|
|
280
|
+
//
|
|
281
|
+
// Returns the promoted row, or null when there was nothing to promote.
|
|
282
|
+
async function promotePlanningVersion(exec) {
|
|
283
|
+
const { rows } = await exec.query(
|
|
284
|
+
`UPDATE versions
|
|
285
|
+
SET status = 'building', started_at = COALESCE(started_at, now())
|
|
286
|
+
WHERE id = (SELECT id FROM versions WHERE status = 'planning' ORDER BY id LIMIT 1)
|
|
287
|
+
AND NOT EXISTS (SELECT 1 FROM versions WHERE status = 'building')
|
|
288
|
+
RETURNING id, name, track, status, started_at`
|
|
289
|
+
);
|
|
290
|
+
return rows[0] ?? null;
|
|
291
|
+
}
|
|
292
|
+
|
|
256
293
|
async function closeVersion({ versionId, plan = [], reason, planningVersionId = null }, deps = {}) {
|
|
257
294
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
258
295
|
const client = await activePool.connect();
|
|
@@ -437,6 +474,7 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
|
|
|
437
474
|
|
|
438
475
|
module.exports = {
|
|
439
476
|
closeVersion,
|
|
477
|
+
promotePlanningVersion,
|
|
440
478
|
createVersion,
|
|
441
479
|
maintenanceGoalExemptSql,
|
|
442
480
|
openNonMaintenanceGoals,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -63,7 +63,7 @@ const {
|
|
|
63
63
|
singleRungPromotionError,
|
|
64
64
|
xenosClaimAllowed,
|
|
65
65
|
} = require('./db-rank-authz.js');
|
|
66
|
-
const { closeVersion, createVersion, currentBuildingVersionId, getVersion, listVersions, maintenanceGoalExemptSql, openNonMaintenanceGoals, versionProgress, versionsWithStatus } = require('./db-versions.js');
|
|
66
|
+
const { closeVersion, promotePlanningVersion, createVersion, currentBuildingVersionId, getVersion, listVersions, maintenanceGoalExemptSql, openNonMaintenanceGoals, versionProgress, versionsWithStatus } = require('./db-versions.js');
|
|
67
67
|
const {
|
|
68
68
|
acceptMembershipRequestAndAddMember,
|
|
69
69
|
achieveGoalIfComplete,
|
|
@@ -222,6 +222,7 @@ module.exports = {
|
|
|
222
222
|
openNonMaintenanceGoals,
|
|
223
223
|
versionsWithStatus,
|
|
224
224
|
closeVersion,
|
|
225
|
+
promotePlanningVersion,
|
|
225
226
|
createVersion,
|
|
226
227
|
currentBuildingVersionId,
|
|
227
228
|
createGoal,
|
|
@@ -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
|
|
@@ -287,12 +287,53 @@ module.exports = function buildVersionsRouter() {
|
|
|
287
287
|
try {
|
|
288
288
|
const out = await db.closeVersion({
|
|
289
289
|
versionId, plan: plan.plan, reason,
|
|
290
|
+
// BV1.R20 (task 1003607, ADR 0263 §8): the close's last act, inside its
|
|
291
|
+
// own transaction. A project with zero building versions is a BROKEN
|
|
292
|
+
// state — `currentBuildingVersionId` returns null and nothing is
|
|
293
|
+
// claimable — so promoting in a second transaction would leave exactly
|
|
294
|
+
// that window open.
|
|
295
|
+
//
|
|
296
|
+
// ORDER IS LOAD-BEARING: promote, THEN carry the bugs forward.
|
|
297
|
+
// `ensureMaintenanceGoal` returns null for a version that is not yet
|
|
298
|
+
// `building`, so a carry-over run first silently carries nothing and the
|
|
299
|
+
// defect queue stays entombed on the version that just shipped.
|
|
300
|
+
//
|
|
301
|
+
// COMPOSED HERE rather than inside closeVersion because the two halves
|
|
302
|
+
// live in different db units (versions and goals, siblings in the
|
|
303
|
+
// module's require DAG) and the route is the one place already holding
|
|
304
|
+
// the whole facade — reaching sideways between the units is how that DAG
|
|
305
|
+
// stops being one.
|
|
306
|
+
//
|
|
307
|
+
// Zero planning versions is NOT an error: the close succeeds and the
|
|
308
|
+
// project sits idle until someone cuts the next version.
|
|
290
309
|
// R04 guarantees at most one planning version, so [0] is THE successor
|
|
291
310
|
// rather than a pick. Null when nothing rolls forward, which is the
|
|
292
311
|
// common close.
|
|
293
312
|
planningVersionId: planning[0]?.id ?? null,
|
|
313
|
+
}, {
|
|
314
|
+
// NOTE THE SECOND ARGUMENT. `closeVersion` reads this hook from `deps`,
|
|
315
|
+
// not from its options object — putting it in the first one is silently
|
|
316
|
+
// ignored, the hook never runs, and the close still returns 200 having
|
|
317
|
+
// promoted nothing. Caught here by running the real close against a real
|
|
318
|
+
// Postgres; no fake would have noticed.
|
|
319
|
+
onClosed: async (client, { versionId: closedId }) => {
|
|
320
|
+
const promoted = await db.promotePlanningVersion(client);
|
|
321
|
+
const carried = promoted
|
|
322
|
+
? await db.carryBugsForward({ fromVersionId: closedId, toVersionId: promoted.id }, { client })
|
|
323
|
+
: null;
|
|
324
|
+
return { promoted, carried };
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
// `promoted` and `carried` ride the response because a close now changes
|
|
328
|
+
// WHICH version is live, and a caller that has to go and re-read
|
|
329
|
+
// /versions to discover that has been told half the outcome. `promoted:
|
|
330
|
+
// null` is the honest answer for a project with nothing scoped next, not
|
|
331
|
+
// an omission.
|
|
332
|
+
return res.json({
|
|
333
|
+
ok: true, version: out.version, applied: out.applied,
|
|
334
|
+
promoted: out.after?.promoted ?? null,
|
|
335
|
+
carried: out.after?.carried ?? null,
|
|
294
336
|
});
|
|
295
|
-
return res.json({ ok: true, version: out.version, applied: out.applied });
|
|
296
337
|
} catch (err) {
|
|
297
338
|
if (err && err.code === 'VERSION_NOT_FOUND') return res.fail('version_not_found', 404);
|
|
298
339
|
if (err && err.code === 'VERSION_NOT_BUILDING') {
|
|
@@ -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.601",
|
|
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.601",
|
|
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.601",
|
|
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.601'; // 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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// tests/version_promotion.mjs — the planning version promotes itself when the
|
|
2
|
+
// building version closes (BV1.R20, task 1003607, goal 1000086, ADR 0263 §8).
|
|
3
|
+
//
|
|
4
|
+
// WHY IT RUNS INSIDE THE CLOSE'S TRANSACTION. A project with zero building
|
|
5
|
+
// versions is a BROKEN state, not an intermediate one: `currentBuildingVersionId`
|
|
6
|
+
// returns null there and every routing path closed over it fails, so nothing is
|
|
7
|
+
// claimable. Promoting in a second transaction leaves exactly that window open,
|
|
8
|
+
// however briefly.
|
|
9
|
+
//
|
|
10
|
+
// AND WHY THE ORDER IS LOAD-BEARING. Promote, THEN carry the bugs forward.
|
|
11
|
+
// `ensureMaintenanceGoal` returns null for a version that is not yet `building`,
|
|
12
|
+
// so a carry-over run first silently carries nothing and the defect queue stays
|
|
13
|
+
// entombed on the version that just shipped — the 1000771 shape, arriving through
|
|
14
|
+
// the close instead of through routing.
|
|
15
|
+
//
|
|
16
|
+
// ONE MORE THING THIS FILE PINS, because it is the bug that actually happened
|
|
17
|
+
// while building R20: `closeVersion` reads the hook from its SECOND argument
|
|
18
|
+
// (`deps`), not from its options object. Passing `onClosed` in the first one is
|
|
19
|
+
// silently ignored — the hook never runs, nothing is promoted, and the close
|
|
20
|
+
// still answers 200. No fake caught it; running the real close against a real
|
|
21
|
+
// Postgres did.
|
|
22
|
+
//
|
|
23
|
+
// Run: node --test tests/version_promotion.mjs
|
|
24
|
+
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import { test } from 'node:test';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { makeSqlAwareClient } from './helpers.mjs';
|
|
30
|
+
|
|
31
|
+
process.env.NODE_ENV = 'test';
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const { promotePlanningVersion } = require('../modules/lifecycle/db-versions.js');
|
|
34
|
+
|
|
35
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
36
|
+
const ROUTES = src('modules/lifecycle/routes/versions.js');
|
|
37
|
+
const DBV = src('modules/lifecycle/db-versions.js');
|
|
38
|
+
|
|
39
|
+
const recorder = (handler) => {
|
|
40
|
+
const seen = [];
|
|
41
|
+
const c = makeSqlAwareClient((sql, params) => { seen.push({ sql: String(sql).replace(/\s+/g, ' '), params }); return handler(sql, params); });
|
|
42
|
+
return { seen, exec: { query: (sql, params) => c._client.query(sql, params) } };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// The promotion itself
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
test('promotion is ONE self-guarding statement, not a read-then-write', async () => {
|
|
50
|
+
// A read-then-write leaves a window in which a concurrent close promotes too,
|
|
51
|
+
// producing the second building version R19 forbids. The NOT EXISTS is
|
|
52
|
+
// evaluated inside the same statement, so it cannot be raced past.
|
|
53
|
+
const r = recorder(() => ({ rows: [] }));
|
|
54
|
+
await promotePlanningVersion(r.exec);
|
|
55
|
+
assert.equal(r.seen.length, 1, 'one statement — a separate check could be raced');
|
|
56
|
+
const sql = r.seen[0].sql;
|
|
57
|
+
assert.match(sql, /UPDATE versions SET status = 'building'/);
|
|
58
|
+
assert.match(sql, /WHERE status = 'planning' ORDER BY id LIMIT 1/);
|
|
59
|
+
assert.match(sql, /NOT EXISTS \(SELECT 1 FROM versions WHERE status = 'building'\)/,
|
|
60
|
+
'the guard against creating a second building version must be in the statement');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('started_at is stamped only if it was never set', async () => {
|
|
64
|
+
const r = recorder(() => ({ rows: [] }));
|
|
65
|
+
await promotePlanningVersion(r.exec);
|
|
66
|
+
assert.match(r.seen[0].sql, /started_at = COALESCE\(started_at, now\(\)\)/,
|
|
67
|
+
"a version's start is when it began building; a re-promotion must not rewrite history");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('nothing to promote returns null — an idle project is not an error', async () => {
|
|
71
|
+
const r = recorder(() => ({ rows: [] }));
|
|
72
|
+
assert.equal(await promotePlanningVersion(r.exec), null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the promoted row is returned so the caller can carry bugs into it', async () => {
|
|
76
|
+
const r = recorder(() => ({ rows: [{ id: 'V2', name: 'two', status: 'building', started_at: 't' }] }));
|
|
77
|
+
const out = await promotePlanningVersion(r.exec);
|
|
78
|
+
assert.equal(out.id, 'V2');
|
|
79
|
+
assert.equal(out.status, 'building');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// The wiring — where the bug was
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
test('onClosed is passed as the DEPS argument, not in the options object', () => {
|
|
87
|
+
// THE BUG THIS PINS. `closeVersion({...opts}, deps)` reads `deps.onClosed`.
|
|
88
|
+
// Putting the hook in the first object is silently ignored: no promotion, no
|
|
89
|
+
// carry-over, and a 200 response that looks correct.
|
|
90
|
+
const call = ROUTES.slice(ROUTES.indexOf('const out = await db.closeVersion('), ROUTES.indexOf('return res.json({ ok: true, version: out.version'));
|
|
91
|
+
const optsEnd = call.indexOf('}, {');
|
|
92
|
+
assert.ok(optsEnd !== -1, 'the close must be called with a second (deps) argument');
|
|
93
|
+
assert.equal(call.slice(0, optsEnd).includes('onClosed'), false,
|
|
94
|
+
'onClosed in the options object is a silent no-op — it belongs in deps');
|
|
95
|
+
assert.ok(call.slice(optsEnd).includes('onClosed'), 'and it must actually be in deps');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the hook promotes BEFORE it carries — the ordering trap', () => {
|
|
99
|
+
const hook = ROUTES.slice(ROUTES.indexOf('onClosed: async (client'), ROUTES.indexOf('return { promoted, carried };'));
|
|
100
|
+
const promoteAt = hook.indexOf('promotePlanningVersion');
|
|
101
|
+
const carryAt = hook.indexOf('carryBugsForward');
|
|
102
|
+
assert.ok(promoteAt !== -1 && carryAt !== -1);
|
|
103
|
+
assert.ok(promoteAt < carryAt,
|
|
104
|
+
'ensureMaintenanceGoal returns null for a non-building version, so carrying first silently carries nothing');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('no promotion means no carry attempt at all', () => {
|
|
108
|
+
// Not "carry into null and hope" — there is genuinely nowhere to put them, and
|
|
109
|
+
// the honest answer is that the bugs stay findable on the closed version until
|
|
110
|
+
// someone cuts the next one.
|
|
111
|
+
assert.match(ROUTES, /const carried = promoted\s*\n?\s*\?\s*await db\.carryBugsForward/);
|
|
112
|
+
assert.match(ROUTES, /:\s*null;/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the close reports what it promoted and carried', () => {
|
|
116
|
+
// A close now changes WHICH version is live. A caller made to re-read /versions
|
|
117
|
+
// to discover that has been told half the outcome.
|
|
118
|
+
assert.match(ROUTES, /promoted: out\.after\?\.promoted \?\? null/);
|
|
119
|
+
assert.match(ROUTES, /carried: out\.after\?\.carried \?\? null/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Composition
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
test('the hook is composed in the ROUTE, not inside closeVersion', () => {
|
|
127
|
+
// The two halves live in different db units — versions and goals, siblings in
|
|
128
|
+
// the module's require DAG — and the route is the one place already holding the
|
|
129
|
+
// whole facade. Reaching sideways between the units is how that DAG stops being
|
|
130
|
+
// one.
|
|
131
|
+
assert.equal(/carryBugsForward/.test(DBV), false,
|
|
132
|
+
'db-versions.js must not reach into db-goals.js');
|
|
133
|
+
assert.match(ROUTES, /db\.promotePlanningVersion\(client\)/);
|
|
134
|
+
assert.match(ROUTES, /db\.carryBugsForward\(/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('the stale R17 note in closeVersion was corrected, not left to mislead', () => {
|
|
138
|
+
// It said roll_forward "only ABSTAINS from cutting the goal ... until R17
|
|
139
|
+
// ships". R17 shipped; a reader meeting the old wording would go looking for
|
|
140
|
+
// an abstention that is no longer there.
|
|
141
|
+
assert.equal(/Until it ships/.test(DBV), false);
|
|
142
|
+
assert.match(DBV, /It DOES create successor goals now/);
|
|
143
|
+
});
|