@bongos/core 1.19.597 → 1.19.598
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 +19 -14
- package/docs/module-api-changelog.md +2 -0
- package/modules/lifecycle/db-versions.js +98 -12
- package/modules/lifecycle/routes/versions.js +15 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/version_close_route.mjs +40 -8
- package/tests/version_roll_forward.mjs +166 -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.598",
|
|
6
|
+
"core_contract": "1.19.598",
|
|
7
|
+
"source_commit": "765987c9bfd5277ee8bd4a6dc5c73f6aab9224bc",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-08T04:
|
|
9
|
+
"built_at": "2026-09-08T04:22:59.900Z",
|
|
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": 2074,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2555,
|
|
20
|
+
"tree_sha256": "2193f9634af7589ddd47ef00b03be7824679582ec0ca3688709c68ffe2e78d7b",
|
|
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": "162a1e6f165acd0b744497fd19ecc34fa10a9229dca13c7b1d35c795511a85b4"
|
|
2731
2731
|
},
|
|
2732
2732
|
{
|
|
2733
2733
|
"path": "docs/modules-contract.md",
|
|
@@ -5567,7 +5567,7 @@
|
|
|
5567
5567
|
{
|
|
5568
5568
|
"path": "modules/lifecycle/db-versions.js",
|
|
5569
5569
|
"mode": "0000644",
|
|
5570
|
-
"sha256": "
|
|
5570
|
+
"sha256": "53f87c20f89c33a3d14ee4410bafa0f3122fb7534708ce5bdacb8cc1098a487c"
|
|
5571
5571
|
},
|
|
5572
5572
|
{
|
|
5573
5573
|
"path": "modules/lifecycle/db.js",
|
|
@@ -5817,7 +5817,7 @@
|
|
|
5817
5817
|
{
|
|
5818
5818
|
"path": "modules/lifecycle/routes/versions.js",
|
|
5819
5819
|
"mode": "0000644",
|
|
5820
|
-
"sha256": "
|
|
5820
|
+
"sha256": "35919f2cdd1e9a5da11a89adc9731818aa6b1b292c0754d9123a4cd8324bd970"
|
|
5821
5821
|
},
|
|
5822
5822
|
{
|
|
5823
5823
|
"path": "modules/lifecycle/routes/visuals.js",
|
|
@@ -7622,12 +7622,12 @@
|
|
|
7622
7622
|
{
|
|
7623
7623
|
"path": "package-lock.json",
|
|
7624
7624
|
"mode": "0000644",
|
|
7625
|
-
"sha256": "
|
|
7625
|
+
"sha256": "a20139fc06badf4713e2ad023c865eb4339b49d1caabfa79d5ea8282c1e13cea"
|
|
7626
7626
|
},
|
|
7627
7627
|
{
|
|
7628
7628
|
"path": "package.json",
|
|
7629
7629
|
"mode": "0000644",
|
|
7630
|
-
"sha256": "
|
|
7630
|
+
"sha256": "cfc6a9c8fd26745544ca7256e40c41929174e617e199cf972e1df59f68e98e51"
|
|
7631
7631
|
},
|
|
7632
7632
|
{
|
|
7633
7633
|
"path": "public-docs/index.html",
|
|
@@ -9342,7 +9342,7 @@
|
|
|
9342
9342
|
{
|
|
9343
9343
|
"path": "src/module-api.js",
|
|
9344
9344
|
"mode": "0000644",
|
|
9345
|
-
"sha256": "
|
|
9345
|
+
"sha256": "922414a017882732619bc7ae7c14ec5c056c0bd2f950447ca9abac23d0b3a687"
|
|
9346
9346
|
},
|
|
9347
9347
|
{
|
|
9348
9348
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12702,7 +12702,7 @@
|
|
|
12702
12702
|
{
|
|
12703
12703
|
"path": "tests/version_close_route.mjs",
|
|
12704
12704
|
"mode": "0000644",
|
|
12705
|
-
"sha256": "
|
|
12705
|
+
"sha256": "56c480792cf88946eb3e3663a23e0431e6d8df3cf8219fdf9b71283b772355bb"
|
|
12706
12706
|
},
|
|
12707
12707
|
{
|
|
12708
12708
|
"path": "tests/version_literals.mjs",
|
|
@@ -12714,6 +12714,11 @@
|
|
|
12714
12714
|
"mode": "0000644",
|
|
12715
12715
|
"sha256": "692261c26e3da46519757733a7fcf9a9998e0942bc5890688b66b79590d82c27"
|
|
12716
12716
|
},
|
|
12717
|
+
{
|
|
12718
|
+
"path": "tests/version_roll_forward.mjs",
|
|
12719
|
+
"mode": "0000644",
|
|
12720
|
+
"sha256": "fa0f079b8dff816806788bc34b6beb31e3366d5c7166e531108fe4ea40890373"
|
|
12721
|
+
},
|
|
12717
12722
|
{
|
|
12718
12723
|
"path": "tests/versions_route.mjs",
|
|
12719
12724
|
"mode": "0000644",
|
|
@@ -1643,5 +1643,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1643
1643
|
landed since 1.19.595 with no explicit bump. run 34185477798. (task 1002620)
|
|
1644
1644
|
1.19.597 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1645
1645
|
landed since 1.19.596 with no explicit bump. run 34186368179. (task 1002620)
|
|
1646
|
+
1.19.598 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1647
|
+
landed since 1.19.597 with no explicit bump. run 34186758247. (task 1002620)
|
|
1646
1648
|
---------------------------------------------------------------------------
|
|
1647
1649
|
```
|
|
@@ -253,7 +253,7 @@ async function openNonMaintenanceGoals(versionId, deps = {}) {
|
|
|
253
253
|
// route cannot write one, and file I/O has no business in this transaction.
|
|
254
254
|
//
|
|
255
255
|
// `plan` is planVersionClose's validated output: [{ goalId, verb }].
|
|
256
|
-
async function closeVersion({ versionId, plan = [], reason }, deps = {}) {
|
|
256
|
+
async function closeVersion({ versionId, plan = [], reason, planningVersionId = null }, deps = {}) {
|
|
257
257
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
258
258
|
const client = await activePool.connect();
|
|
259
259
|
try {
|
|
@@ -291,10 +291,19 @@ async function closeVersion({ versionId, plan = [], reason }, deps = {}) {
|
|
|
291
291
|
// close's reason — the same stamp R14 writes, so the ledger reads
|
|
292
292
|
// identically whichever door retired the work.
|
|
293
293
|
const { rows: killed } = await client.query(
|
|
294
|
+
// `nonTerminalSql('status')`, NOT the default. The default is `t.status`,
|
|
295
|
+
// which is right for the SELECTs in this file (they alias `tasks t`) and
|
|
296
|
+
// is a HARD SQL ERROR here — an `UPDATE tasks` declares no alias, so
|
|
297
|
+
// Postgres answers `missing FROM-clause entry for table "t"` and the
|
|
298
|
+
// whole close transaction throws. Every `abandon` disposition was
|
|
299
|
+
// failing on this (shipped in R12, task 1003599); nothing caught it
|
|
300
|
+
// because the suite drives this path with a SQL-AWARE FAKE that records
|
|
301
|
+
// statements instead of parsing them. Found by running the rendered
|
|
302
|
+
// statement against a real Postgres while building R17.
|
|
294
303
|
`UPDATE tasks SET status = 'abandoned',
|
|
295
304
|
value_summary = COALESCE(NULLIF($2, ''), value_summary),
|
|
296
305
|
updated_at = now()
|
|
297
|
-
WHERE goal_id = $1 AND ${nonTerminalSql()}
|
|
306
|
+
WHERE goal_id = $1 AND ${nonTerminalSql('status')}
|
|
298
307
|
AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
|
|
299
308
|
RETURNING id`,
|
|
300
309
|
[step.goalId, `Abandoned: ${reason}`]
|
|
@@ -302,23 +311,100 @@ async function closeVersion({ versionId, plan = [], reason }, deps = {}) {
|
|
|
302
311
|
await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
|
|
303
312
|
applied.push({ goal_id: String(step.goalId), verb: 'abandon', result: 'archived', tasks_abandoned: killed.length });
|
|
304
313
|
} else {
|
|
305
|
-
// roll_forward
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
314
|
+
// roll_forward (BV1.R17, task 1003604, ADR 0263 §6): the goal survives the
|
|
315
|
+
// version boundary as a NEW goal on the planning version, and the old one
|
|
316
|
+
// archives behind it with its lineage recorded.
|
|
317
|
+
//
|
|
318
|
+
// THE COLUMN IS `succeeded_by_goal_id`, and it points the other way from
|
|
319
|
+
// what ADR 0250 and this task's own brief said. Migration 160 put it on
|
|
320
|
+
// the OLD goal pointing at the NEW one; `succeeds_goal_id` has never
|
|
321
|
+
// existed. The design pass caught that, which is why there is no
|
|
322
|
+
// migration here.
|
|
323
|
+
if (!planningVersionId) {
|
|
324
|
+
// The route refuses this before calling (rollForwardNeedsPlanningVersion),
|
|
325
|
+
// so reaching it means a caller bypassed the route. Fail rather than
|
|
326
|
+
// silently leaving the goal on a shipped version.
|
|
327
|
+
throw Object.assign(new Error('roll_forward with no planning version'), {
|
|
328
|
+
code: 'NO_PLANNING_VERSION', goalId: String(step.goalId),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
// The successor carries what makes the goal itself — its prose, its scope
|
|
332
|
+
// wall, its category and its visibility. It does NOT carry `is_maintenance`
|
|
333
|
+
// (the successor version gets its own via ensureMaintenanceGoal) and it
|
|
334
|
+
// does not carry admissions: an override that widened THIS version's scope
|
|
335
|
+
// is spent, and re-creating the row would launder a one-time exception into
|
|
336
|
+
// a permanent one.
|
|
337
|
+
const { rows: made } = await client.query(
|
|
338
|
+
`WITH next_order AS (
|
|
339
|
+
SELECT COALESCE(MAX(sort_order) + 1, 0) AS n FROM goals WHERE version_id = $2
|
|
340
|
+
)
|
|
341
|
+
INSERT INTO goals (version_id, title, subtitle, description, scope_modules,
|
|
342
|
+
status, visibility, accepting_requests, created_by, sort_order, category_id)
|
|
343
|
+
SELECT $2, g.title, g.subtitle, g.description, g.scope_modules,
|
|
344
|
+
'open', g.visibility, g.accepting_requests, g.created_by, next_order.n, g.category_id
|
|
345
|
+
FROM goals g, next_order
|
|
346
|
+
WHERE g.id = $1
|
|
347
|
+
RETURNING id`,
|
|
348
|
+
[step.goalId, planningVersionId]
|
|
349
|
+
);
|
|
350
|
+
const successorId = made[0].id;
|
|
351
|
+
|
|
352
|
+
// MEMBERSHIP TRAVELS, because a goal without its people is a new goal
|
|
353
|
+
// wearing the old one's title: the builders who were carrying the work are
|
|
354
|
+
// exactly the ones who should still find it on their board after the
|
|
355
|
+
// boundary. `membership_kind` is copied verbatim so the manager stays the
|
|
356
|
+
// manager (ADR 0177 — never the word `role` here).
|
|
357
|
+
await client.query(
|
|
358
|
+
`INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
359
|
+
SELECT $2, builder_id, membership_kind FROM goal_members WHERE goal_id = $1
|
|
360
|
+
ON CONFLICT DO NOTHING`,
|
|
361
|
+
[step.goalId, successorId]
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// The unfinished work moves; the finished work stays. A shipped task is
|
|
365
|
+
// the record of what THIS version delivered and must not be re-pointed at
|
|
366
|
+
// the next one, or every version's history migrates forward with it.
|
|
367
|
+
// Smoke fixtures are excluded on the same terms as the abandon branch, so
|
|
368
|
+
// both doors agree about what "unfinished" means.
|
|
369
|
+
const { rows: moved } = await client.query(
|
|
370
|
+
`UPDATE tasks SET goal_id = $2, updated_at = now()
|
|
371
|
+
WHERE goal_id = $1 AND ${nonTerminalSql('status')}
|
|
372
|
+
AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
|
|
373
|
+
RETURNING id`,
|
|
374
|
+
[step.goalId, successorId]
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
await client.query(
|
|
378
|
+
`UPDATE goals SET succeeded_by_goal_id = $2, updated_at = now() WHERE id = $1`,
|
|
379
|
+
[step.goalId, successorId]
|
|
380
|
+
);
|
|
381
|
+
// Archived LAST, and only now legitimately: the predecessor holds zero
|
|
382
|
+
// unfinished tasks at this point, so it satisfies R10's own closure check
|
|
383
|
+
// on the way out rather than being special-cased around it (the brief's
|
|
384
|
+
// explicit ask).
|
|
385
|
+
await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
|
|
386
|
+
|
|
387
|
+
applied.push({
|
|
388
|
+
goal_id: String(step.goalId), verb: 'roll_forward', result: 'succeeded',
|
|
389
|
+
successor_goal_id: String(successorId), tasks_moved: moved.length,
|
|
390
|
+
});
|
|
309
391
|
}
|
|
310
392
|
}
|
|
311
393
|
|
|
312
394
|
// The invariant, asserted against committed-in-txn state rather than against
|
|
313
|
-
// the plan — the one check a bug in the planner cannot fool.
|
|
314
|
-
//
|
|
315
|
-
|
|
395
|
+
// the plan — the one check a bug in the planner cannot fool.
|
|
396
|
+
//
|
|
397
|
+
// R17 (task 1003604) REMOVED THE CARVE-OUT. This used to exclude the
|
|
398
|
+
// `roll_forward` goals by id, because they were left open pending a successor;
|
|
399
|
+
// now they archive behind their successor like any other closed goal, so the
|
|
400
|
+
// check is the whole truth again: after a close, NO non-maintenance goal on
|
|
401
|
+
// this version is still open. An exclusion list is a hole in an invariant, and
|
|
402
|
+
// this one no longer has a reason to exist.
|
|
316
403
|
const { rows: left } = await client.query(
|
|
317
404
|
`SELECT count(*)::int AS n FROM goals g
|
|
318
405
|
WHERE g.version_id = $1 AND g.status = 'open'
|
|
319
|
-
AND ${maintenanceGoalExemptSql('$1')}
|
|
320
|
-
|
|
321
|
-
[versionId, carried]
|
|
406
|
+
AND ${maintenanceGoalExemptSql('$1')}`,
|
|
407
|
+
[versionId]
|
|
322
408
|
);
|
|
323
409
|
if ((left[0]?.n ?? 0) > 0) {
|
|
324
410
|
throw Object.assign(new Error('version still holds open goals after dispositions'), {
|
|
@@ -274,14 +274,24 @@ module.exports = function buildVersionsRouter() {
|
|
|
274
274
|
// REFUSAL rather than an auto-create: cutting the successor is a scope
|
|
275
275
|
// decision with its own criteria, and doing it implicitly inside a close is
|
|
276
276
|
// the "fake hotfix version" ADR 0250 §3 built the override counter to prevent.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
277
|
+
// Read ONCE and keep it: the same rows decide the refusal AND name the
|
|
278
|
+
// destination the successors are created on (BV1.R17, task 1003604). Reading
|
|
279
|
+
// it twice would leave a window in which the refusal passed against one
|
|
280
|
+
// planning version and the write landed on another.
|
|
281
|
+
const planning = plan.plan.some((p) => p.verb === 'roll_forward')
|
|
282
|
+
? await db.versionsWithStatus('planning')
|
|
283
|
+
: [];
|
|
284
|
+
const needsPlanning = rollForwardNeedsPlanningVersion({ plan: plan.plan, planning });
|
|
281
285
|
if (needsPlanning) return res.status(needsPlanning.status).json(needsPlanning.body);
|
|
282
286
|
|
|
283
287
|
try {
|
|
284
|
-
const out = await db.closeVersion({
|
|
288
|
+
const out = await db.closeVersion({
|
|
289
|
+
versionId, plan: plan.plan, reason,
|
|
290
|
+
// R04 guarantees at most one planning version, so [0] is THE successor
|
|
291
|
+
// rather than a pick. Null when nothing rolls forward, which is the
|
|
292
|
+
// common close.
|
|
293
|
+
planningVersionId: planning[0]?.id ?? null,
|
|
294
|
+
});
|
|
285
295
|
return res.json({ ok: true, version: out.version, applied: out.applied });
|
|
286
296
|
} catch (err) {
|
|
287
297
|
if (err && err.code === 'VERSION_NOT_FOUND') return res.fail('version_not_found', 404);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.598",
|
|
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.598",
|
|
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.598",
|
|
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.598'; // 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');
|
|
@@ -163,6 +163,11 @@ function closePool({ status = 'building', goalStatus = 'open', remaining = 0 } =
|
|
|
163
163
|
if (/FROM versions WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 'V1', status }] };
|
|
164
164
|
if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'g', status: goalStatus }] };
|
|
165
165
|
if (/UPDATE tasks SET status = 'abandoned'/.test(q)) return { rows: [{ id: 11 }, { id: 12 }] };
|
|
166
|
+
// R17 (task 1003604) — the roll_forward writes: the successor INSERT, the
|
|
167
|
+
// membership copy, and the task re-point.
|
|
168
|
+
if (/INSERT INTO goals/.test(q)) return { rows: [{ id: 99 }] };
|
|
169
|
+
if (/INSERT INTO goal_members/.test(q)) return { rows: [] };
|
|
170
|
+
if (/UPDATE tasks SET goal_id/.test(q)) return { rows: [{ id: 21 }, { id: 22 }] };
|
|
166
171
|
if (/count\(\*\)::int AS n FROM goals/.test(q)) return { rows: [{ n: remaining }] };
|
|
167
172
|
if (/UPDATE versions SET status = 'shipped'/.test(q)) return { rows: [{ id: 'V1', status: 'shipped' }] };
|
|
168
173
|
return { rows: [] };
|
|
@@ -193,21 +198,48 @@ await test('an abandoned goal takes its open tasks with it, stamped with the clo
|
|
|
193
198
|
assert.match(flat(kill), /NOT IN \('shipped', 'abandoned'\)/, 'and only the unfinished ones');
|
|
194
199
|
});
|
|
195
200
|
|
|
196
|
-
await test('a roll_forward abandons NOTHING
|
|
201
|
+
await test('a roll_forward abandons NOTHING — it creates the successor and moves the work', async () => {
|
|
202
|
+
// BOTH HALVES OF THIS FILE'S roll_forward EXPECTATION FLIPPED AT R17
|
|
203
|
+
// (task 1003604). R12 left the verb as a placeholder that reported
|
|
204
|
+
// `carried_pending_successor` and wrote nothing; the successor, the lineage
|
|
205
|
+
// and the task move land now. What has NOT changed, and is the assertion
|
|
206
|
+
// worth keeping, is that carrying a goal forward never CUTS its work.
|
|
197
207
|
const pool = closePool();
|
|
198
|
-
const out = await closeVersion(
|
|
199
|
-
|
|
208
|
+
const out = await closeVersion(
|
|
209
|
+
{ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r', planningVersionId: 'V2' },
|
|
210
|
+
{ pool }
|
|
211
|
+
);
|
|
212
|
+
assert.equal(out.applied[0].verb, 'roll_forward');
|
|
213
|
+
assert.equal(out.applied[0].result, 'succeeded');
|
|
214
|
+
assert.ok('successor_goal_id' in out.applied[0], 'the response must name the goal that carries on');
|
|
200
215
|
assert.equal(pool.queries.some((q) => /UPDATE tasks SET status = 'abandoned'/.test(flat(q))), false,
|
|
201
216
|
'carrying a goal forward must never cut its work');
|
|
202
|
-
assert.equal(pool.queries.some((q) => /UPDATE goals SET status = 'archived'/.test(flat(q))), false);
|
|
203
217
|
});
|
|
204
218
|
|
|
205
|
-
await test('a
|
|
219
|
+
await test('a roll_forward with no planning version throws rather than stranding the goal', async () => {
|
|
220
|
+
// The route refuses this first (rollForwardNeedsPlanningVersion), so reaching
|
|
221
|
+
// the writer means a caller bypassed the route. Failing beats silently leaving
|
|
222
|
+
// the goal open on a version that is about to ship.
|
|
206
223
|
const pool = closePool();
|
|
207
|
-
await
|
|
224
|
+
await assert.rejects(
|
|
225
|
+
closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool }),
|
|
226
|
+
(e) => e.code === 'NO_PLANNING_VERSION'
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await test('the post-apply re-count has NO carve-out — every non-maintenance goal must be closed', async () => {
|
|
231
|
+
// This used to assert the opposite: carried goals were excluded by id, because
|
|
232
|
+
// they were left open pending a successor. They archive now, so the check is
|
|
233
|
+
// the whole truth again. An exclusion list is a hole in an invariant, and this
|
|
234
|
+
// one no longer has a reason to exist.
|
|
235
|
+
const pool = closePool();
|
|
236
|
+
await closeVersion(
|
|
237
|
+
{ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r', planningVersionId: 'V2' },
|
|
238
|
+
{ pool }
|
|
239
|
+
);
|
|
208
240
|
const recount = pool.queries.find((q) => /count\(\*\)::int AS n FROM goals/.test(flat(q)));
|
|
209
|
-
assert.
|
|
210
|
-
assert.
|
|
241
|
+
assert.equal(/ANY\(\$2::bigint\[\]\)/.test(flat(recount)), false, 'the carve-out must be gone');
|
|
242
|
+
assert.equal(recount.params.length, 1, 'only the version id is bound now');
|
|
211
243
|
});
|
|
212
244
|
|
|
213
245
|
await test('the re-check runs against committed state and rolls back rather than half-closing', async () => {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// tests/version_roll_forward.mjs — a goal that is not finished survives the
|
|
2
|
+
// version boundary as a NEW goal, with its lineage recorded
|
|
3
|
+
// (BV1.R17, task 1003604, goal 1000086, ADR 0263 §6).
|
|
4
|
+
//
|
|
5
|
+
// THE COLUMN POINTS THE OTHER WAY from what ADR 0250 and R17's own brief said.
|
|
6
|
+
// Both name `succeeds_goal_id`, which has never existed; migration 160 gave
|
|
7
|
+
// `goals` a **`succeeded_by_goal_id`** — on the OLD goal, pointing at the NEW
|
|
8
|
+
// one. The design pass caught it, which is why R17 reserved no migration. This
|
|
9
|
+
// file pins the direction so the wrong one cannot come back through a reader who
|
|
10
|
+
// trusts the prose over the schema.
|
|
11
|
+
//
|
|
12
|
+
// AND A NOTE ON WHY THIS FILE EXISTS AT ALL, beyond the feature. The close path
|
|
13
|
+
// is driven in tests/version_close_route.mjs by a SQL-AWARE FAKE that records
|
|
14
|
+
// statements rather than parsing them. That fake happily accepted
|
|
15
|
+
// `UPDATE tasks … WHERE t.status NOT IN (…)` — a hard `missing FROM-clause entry
|
|
16
|
+
// for table "t"` on a real Postgres, which meant every `abandon` disposition
|
|
17
|
+
// threw in production from the day R12 shipped. Nothing caught it because
|
|
18
|
+
// nothing ever ran it. The structural assertions at the bottom are the DB-free
|
|
19
|
+
// guard for that whole CLASS of bug: a rendered predicate whose alias does not
|
|
20
|
+
// match the statement it was spliced into.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/version_roll_forward.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
|
|
29
|
+
process.env.NODE_ENV = 'test';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { nonTerminalSql } = require('../modules/lifecycle/task-status.js');
|
|
32
|
+
|
|
33
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
34
|
+
const DBV = src('modules/lifecycle/db-versions.js');
|
|
35
|
+
const ROUTES = src('modules/lifecycle/routes/versions.js');
|
|
36
|
+
|
|
37
|
+
// The roll_forward branch, sliced so assertions land on the right code.
|
|
38
|
+
const CLOSE = DBV.slice(DBV.indexOf('async function closeVersion'));
|
|
39
|
+
const ROLL = CLOSE.slice(CLOSE.indexOf("verb: 'roll_forward'") - 4000, CLOSE.indexOf('// The invariant'));
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// The lineage
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
test('the lineage column is succeeded_by_goal_id, written on the PREDECESSOR', () => {
|
|
46
|
+
assert.match(ROLL, /UPDATE goals SET succeeded_by_goal_id = \$2[\s\S]{0,80}WHERE id = \$1/);
|
|
47
|
+
// The spelling that does not exist must never reach the SQL. Comments are
|
|
48
|
+
// stripped first: the code deliberately NAMES `succeeds_goal_id` in prose to
|
|
49
|
+
// explain that it was never created, and a check that read the explanation
|
|
50
|
+
// would fail on the warning rather than on a real use.
|
|
51
|
+
const code = DBV.replace(/\/\/[^\n]*/g, '');
|
|
52
|
+
assert.equal(/succeeds_goal_id/.test(code), false,
|
|
53
|
+
'ADR 0250 and the task brief both name a column migration 160 never created');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('no migration was reserved — the column already exists', () => {
|
|
57
|
+
// R17's brief says this in capitals, because reserving a number for a column
|
|
58
|
+
// that is already there is the mistake the design pass was written to prevent.
|
|
59
|
+
assert.match(DBV, /succeeded_by_goal_id/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// What the successor carries, and what it deliberately does not
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
test('the successor carries the goal\'s identity, scope and category', () => {
|
|
67
|
+
for (const col of ['title', 'subtitle', 'description', 'scope_modules', 'category_id', 'visibility']) {
|
|
68
|
+
assert.match(ROLL, new RegExp(`g\\.${col}`), `the successor must carry ${col}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('membership travels with it', () => {
|
|
73
|
+
// A goal without its people is a new goal wearing the old one's title: the
|
|
74
|
+
// builders carrying the work are exactly who should still find it after the
|
|
75
|
+
// boundary. membership_kind is copied verbatim so a manager stays a manager.
|
|
76
|
+
assert.match(ROLL, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/);
|
|
77
|
+
assert.match(ROLL, /SELECT \$2, builder_id, membership_kind FROM goal_members WHERE goal_id = \$1/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('the successor does NOT inherit is_maintenance or an admission', () => {
|
|
81
|
+
const insert = ROLL.slice(ROLL.indexOf('INSERT INTO goals'), ROLL.indexOf('RETURNING id'));
|
|
82
|
+
assert.equal(/is_maintenance/.test(insert), false,
|
|
83
|
+
'the successor version gets its own maintenance goal via ensureMaintenanceGoal');
|
|
84
|
+
assert.equal(/admission/i.test(insert), false,
|
|
85
|
+
'an override that widened THIS version\'s scope is spent — re-creating the row would launder a one-time exception into a permanent one');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// What moves, and what stays
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
test('only UNFINISHED tasks are re-pointed; shipped work stays with the version that delivered it', () => {
|
|
93
|
+
const move = ROLL.slice(ROLL.indexOf('UPDATE tasks SET goal_id'));
|
|
94
|
+
assert.match(move, /nonTerminalSql\('status'\)/);
|
|
95
|
+
assert.match(move, /smoke/, 'smoke fixtures are excluded on the same terms as the abandon branch');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the predecessor is archived LAST, after its work has left', () => {
|
|
99
|
+
// The brief's explicit ask: it archives holding zero unfinished tasks, so it
|
|
100
|
+
// passes R10's own closure check on the way out rather than being
|
|
101
|
+
// special-cased around it.
|
|
102
|
+
const moveAt = ROLL.indexOf('UPDATE tasks SET goal_id');
|
|
103
|
+
const lineageAt = ROLL.indexOf('succeeded_by_goal_id = $2');
|
|
104
|
+
const archiveAt = ROLL.indexOf("status = 'archived'");
|
|
105
|
+
assert.ok(moveAt !== -1 && lineageAt !== -1 && archiveAt !== -1);
|
|
106
|
+
assert.ok(moveAt < archiveAt, 'the tasks must leave before the goal closes');
|
|
107
|
+
assert.ok(lineageAt < archiveAt, 'the lineage must be written before the goal closes');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// The destination
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
test('the route reads the planning version ONCE and passes it to the close', () => {
|
|
115
|
+
// Reading it twice leaves a window where the refusal passed against one
|
|
116
|
+
// planning version and the write landed on another.
|
|
117
|
+
assert.match(ROUTES, /const planning = plan\.plan\.some/);
|
|
118
|
+
assert.match(ROUTES, /rollForwardNeedsPlanningVersion\(\{ plan: plan\.plan, planning \}\)/);
|
|
119
|
+
assert.match(ROUTES, /planningVersionId: planning\[0\]\?\.id \?\? null/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('closeVersion refuses a roll_forward with no destination rather than stranding the goal', () => {
|
|
123
|
+
assert.match(ROLL, /NO_PLANNING_VERSION/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// The invariant lost its carve-out
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
test('the post-apply re-count no longer excludes rolled-forward goals', () => {
|
|
131
|
+
// They used to be left open pending a successor, so the check excluded them by
|
|
132
|
+
// id. They archive now, so the check is the whole truth again: after a close no
|
|
133
|
+
// non-maintenance goal on this version is still open. An exclusion list is a
|
|
134
|
+
// hole in an invariant.
|
|
135
|
+
const recount = CLOSE.slice(CLOSE.indexOf('// The invariant'), CLOSE.indexOf('CLOSE_INCOMPLETE_AFTER_APPLY'));
|
|
136
|
+
assert.equal(/ANY\(\$2::bigint\[\]\)/.test(recount), false, 'the carve-out must be gone');
|
|
137
|
+
assert.equal(/carried/.test(recount), false, 'and its variable with it');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// The alias class-of-bug guard (the reason the abandon branch was broken)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
test('every UPDATE in this file splices an UNALIASED predicate', () => {
|
|
145
|
+
// `nonTerminalSql()` defaults to `t.status`, which is correct for the SELECTs
|
|
146
|
+
// here (they alias `tasks t`) and a hard SQL error inside an `UPDATE tasks`,
|
|
147
|
+
// which declares no alias. R12 shipped exactly that and every abandon
|
|
148
|
+
// disposition threw `missing FROM-clause entry for table "t"` in production.
|
|
149
|
+
// The fake-client suite could not see it, so this reads the source instead.
|
|
150
|
+
const updates = [...DBV.matchAll(/UPDATE (tasks|goals)\b[\s\S]{0,600}?(?=RETURNING|`,)/g)];
|
|
151
|
+
assert.ok(updates.length >= 3, 'expected the close path\'s UPDATE statements');
|
|
152
|
+
for (const m of updates) {
|
|
153
|
+
assert.equal(/\$\{nonTerminalSql\(\)\}/.test(m[0]), false,
|
|
154
|
+
'an UPDATE must call nonTerminalSql(\'status\') — the default t.status has no FROM-clause entry here');
|
|
155
|
+
assert.equal(/\bt\.status\b/.test(m[0]), false, `an UPDATE references t.status: ${m[0].slice(0, 90)}`);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the SELECTs that DO alias tasks may keep the default', () => {
|
|
160
|
+
// The other half of the rule — this is not "never use the default", it is
|
|
161
|
+
// "match the statement". openNonMaintenanceGoals aliases `tasks t` and is right
|
|
162
|
+
// to use it, and a fix that changed every call site would have been wrong.
|
|
163
|
+
assert.match(DBV, /FROM tasks t\s*\n?\s*WHERE t\.goal_id = g\.id AND \$\{nonTerminalSql\(\)\}/);
|
|
164
|
+
assert.equal(nonTerminalSql(), "t.status NOT IN ('shipped', 'abandoned')");
|
|
165
|
+
assert.equal(nonTerminalSql('status'), "status NOT IN ('shipped', 'abandoned')");
|
|
166
|
+
});
|