@bongos/core 1.19.676 → 1.19.678
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 +28 -23
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +6 -1
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +7 -1
- package/modules/lifecycle/db-versions.js +47 -7
- package/modules/lifecycle/routes/versions.js +19 -3
- package/modules/provisioning/provisioning.js +52 -0
- package/modules/provisioning/routes/provisioning.js +22 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/init.js +25 -3
- package/src/module-api.js +1 -1
- package/tests/init.mjs +68 -0
- package/tests/provisioning_settings_apply.mjs +129 -0
- package/tests/version_first_goal.mjs +165 -0
- package/tests/versions_route.mjs +12 -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.678",
|
|
6
|
+
"core_contract": "1.19.678",
|
|
7
|
+
"source_commit": "f16c9e626ff2c55f717a95aee3d34d77b8d1d7cc",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-11T22:19:41.718Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 474,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2124,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2622,
|
|
20
|
+
"tree_sha256": "f74a39b4cffb6701304f05814582718e8b1580ab01d97d6c6be2be8e2ff4e18f",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -387,7 +387,7 @@
|
|
|
387
387
|
{
|
|
388
388
|
"path": "clients/bongos-client/index.d.ts",
|
|
389
389
|
"mode": "0000644",
|
|
390
|
-
"sha256": "
|
|
390
|
+
"sha256": "ba2f6ea9be911785f06761110363357fc1257fb53dadcb51def6194d1f60c9e7"
|
|
391
391
|
},
|
|
392
392
|
{
|
|
393
393
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1912,12 +1912,12 @@
|
|
|
1912
1912
|
{
|
|
1913
1913
|
"path": "docs/api-reference.md",
|
|
1914
1914
|
"mode": "0000644",
|
|
1915
|
-
"sha256": "
|
|
1915
|
+
"sha256": "f4f639df647605d86971b62b81921c77c465ded0782eb29d7fc5254ba2f16d0d"
|
|
1916
1916
|
},
|
|
1917
1917
|
{
|
|
1918
1918
|
"path": "docs/api/openapi.json",
|
|
1919
1919
|
"mode": "0000644",
|
|
1920
|
-
"sha256": "
|
|
1920
|
+
"sha256": "dbe27d28100bdbf007baefc90bfc93692b04fc8772019976292260617af175ac"
|
|
1921
1921
|
},
|
|
1922
1922
|
{
|
|
1923
1923
|
"path": "docs/architecture.md",
|
|
@@ -2792,7 +2792,7 @@
|
|
|
2792
2792
|
{
|
|
2793
2793
|
"path": "docs/module-api-changelog.md",
|
|
2794
2794
|
"mode": "0000644",
|
|
2795
|
-
"sha256": "
|
|
2795
|
+
"sha256": "ec91ef553908baf5180767c401d6b91afc48d938740258e63a48fb88106f7177"
|
|
2796
2796
|
},
|
|
2797
2797
|
{
|
|
2798
2798
|
"path": "docs/modules-contract.md",
|
|
@@ -5662,7 +5662,7 @@
|
|
|
5662
5662
|
{
|
|
5663
5663
|
"path": "modules/lifecycle/db-goals.js",
|
|
5664
5664
|
"mode": "0000644",
|
|
5665
|
-
"sha256": "
|
|
5665
|
+
"sha256": "23df3da3437c14aa3dd1d276d501a9fd42c9937ffdfcec8d7b893d5edfd69469"
|
|
5666
5666
|
},
|
|
5667
5667
|
{
|
|
5668
5668
|
"path": "modules/lifecycle/db-grade.js",
|
|
@@ -5697,7 +5697,7 @@
|
|
|
5697
5697
|
{
|
|
5698
5698
|
"path": "modules/lifecycle/db-versions.js",
|
|
5699
5699
|
"mode": "0000644",
|
|
5700
|
-
"sha256": "
|
|
5700
|
+
"sha256": "7eaaafc9414bf77c12c57eefde02ddf1c67601f4b6b15ded3692bc3765ac036b"
|
|
5701
5701
|
},
|
|
5702
5702
|
{
|
|
5703
5703
|
"path": "modules/lifecycle/db.js",
|
|
@@ -5947,7 +5947,7 @@
|
|
|
5947
5947
|
{
|
|
5948
5948
|
"path": "modules/lifecycle/routes/versions.js",
|
|
5949
5949
|
"mode": "0000644",
|
|
5950
|
-
"sha256": "
|
|
5950
|
+
"sha256": "d69b98e63306141aa01d064b82a249a05b96aa010404cebd00edbc887107e52d"
|
|
5951
5951
|
},
|
|
5952
5952
|
{
|
|
5953
5953
|
"path": "modules/lifecycle/routes/visuals.js",
|
|
@@ -6482,7 +6482,7 @@
|
|
|
6482
6482
|
{
|
|
6483
6483
|
"path": "modules/provisioning/provisioning.js",
|
|
6484
6484
|
"mode": "0000644",
|
|
6485
|
-
"sha256": "
|
|
6485
|
+
"sha256": "0f82fbdf4838a7e72d4128c76b4da7f05ed4578b8a20c167c7fb2e95cb1e9237"
|
|
6486
6486
|
},
|
|
6487
6487
|
{
|
|
6488
6488
|
"path": "modules/provisioning/rate-limit.js",
|
|
@@ -6507,7 +6507,7 @@
|
|
|
6507
6507
|
{
|
|
6508
6508
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6509
6509
|
"mode": "0000644",
|
|
6510
|
-
"sha256": "
|
|
6510
|
+
"sha256": "c83cec200215e9cb04446592e1aed32daf8e083142416bdc1f25d261f460d1cf"
|
|
6511
6511
|
},
|
|
6512
6512
|
{
|
|
6513
6513
|
"path": "modules/provisioning/starter-bundles.js",
|
|
@@ -7767,12 +7767,12 @@
|
|
|
7767
7767
|
{
|
|
7768
7768
|
"path": "package-lock.json",
|
|
7769
7769
|
"mode": "0000644",
|
|
7770
|
-
"sha256": "
|
|
7770
|
+
"sha256": "0edc0468c32498fd329ba23692832935f7524f2b4d24065d9a53cf583728af49"
|
|
7771
7771
|
},
|
|
7772
7772
|
{
|
|
7773
7773
|
"path": "package.json",
|
|
7774
7774
|
"mode": "0000644",
|
|
7775
|
-
"sha256": "
|
|
7775
|
+
"sha256": "32048e9ab629a14af9beee43dba2b8620a0e29db73a806dc66f21d08061a3c8b"
|
|
7776
7776
|
},
|
|
7777
7777
|
{
|
|
7778
7778
|
"path": "public-docs/index.html",
|
|
@@ -8362,7 +8362,7 @@
|
|
|
8362
8362
|
{
|
|
8363
8363
|
"path": "scripts/gds/init.js",
|
|
8364
8364
|
"mode": "0000644",
|
|
8365
|
-
"sha256": "
|
|
8365
|
+
"sha256": "1d63c1485820ad2e81e97979402eabd3829e86503f4c06318a251dc3a6874047"
|
|
8366
8366
|
},
|
|
8367
8367
|
{
|
|
8368
8368
|
"path": "scripts/gds/install-git-hooks.js",
|
|
@@ -9532,7 +9532,7 @@
|
|
|
9532
9532
|
{
|
|
9533
9533
|
"path": "src/module-api.js",
|
|
9534
9534
|
"mode": "0000644",
|
|
9535
|
-
"sha256": "
|
|
9535
|
+
"sha256": "538fc4afaf5a3f0da5eb4bcc3d99533cdf03cc5898163e6b8bcf8ca1584d14ec"
|
|
9536
9536
|
},
|
|
9537
9537
|
{
|
|
9538
9538
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11382,7 +11382,7 @@
|
|
|
11382
11382
|
{
|
|
11383
11383
|
"path": "tests/init.mjs",
|
|
11384
11384
|
"mode": "0000644",
|
|
11385
|
-
"sha256": "
|
|
11385
|
+
"sha256": "4a79313db6d6393415ace3e24f985be397156c10845b6543fdfbda3944ddeec0"
|
|
11386
11386
|
},
|
|
11387
11387
|
{
|
|
11388
11388
|
"path": "tests/instance_config.mjs",
|
|
@@ -12132,7 +12132,7 @@
|
|
|
12132
12132
|
{
|
|
12133
12133
|
"path": "tests/provisioning_settings_apply.mjs",
|
|
12134
12134
|
"mode": "0000644",
|
|
12135
|
-
"sha256": "
|
|
12135
|
+
"sha256": "a55a1c88958710a8b84048b88ba5d68b05e4489242a777852af8a2c9991462fb"
|
|
12136
12136
|
},
|
|
12137
12137
|
{
|
|
12138
12138
|
"path": "tests/provisioning_settings_env.mjs",
|
|
@@ -13014,6 +13014,11 @@
|
|
|
13014
13014
|
"mode": "0000644",
|
|
13015
13015
|
"sha256": "f972971a1f668a085c5f5b4c5ccba616e4b77a3f3308f9a48af33e43ecd62a8c"
|
|
13016
13016
|
},
|
|
13017
|
+
{
|
|
13018
|
+
"path": "tests/version_first_goal.mjs",
|
|
13019
|
+
"mode": "0000644",
|
|
13020
|
+
"sha256": "52e6115ac6af363db16358c1264b01d3295d013db1a986a9c21fdb8ba58f4119"
|
|
13021
|
+
},
|
|
13017
13022
|
{
|
|
13018
13023
|
"path": "tests/version_literals.mjs",
|
|
13019
13024
|
"mode": "0000644",
|
|
@@ -13037,7 +13042,7 @@
|
|
|
13037
13042
|
{
|
|
13038
13043
|
"path": "tests/versions_route.mjs",
|
|
13039
13044
|
"mode": "0000644",
|
|
13040
|
-
"sha256": "
|
|
13045
|
+
"sha256": "1e439ad083cb9a345fafc952d9527979c59d9ef9ca875e4aa1eb602b5227dc22"
|
|
13041
13046
|
},
|
|
13042
13047
|
{
|
|
13043
13048
|
"path": "tests/visibility_pull.mjs",
|
|
@@ -397,8 +397,8 @@ export interface PostVersionsIdCloseRequest { reason?: string; dispositions?: Re
|
|
|
397
397
|
export interface PostVersionsIdCloseResponse { ok: boolean; version: unknown; applied: unknown; promoted: unknown; carried: unknown }
|
|
398
398
|
export interface PostVersionsIdDoneWhenRequest { criterion_id: string; criterion_md: string; goal_id?: number; sort_order?: number }
|
|
399
399
|
export interface PostVersionsIdDoneWhenResponse { ok: boolean; criterion: unknown }
|
|
400
|
-
export interface PostVersionsRequest { id: string; name: string; status?: string; track?: string; done_when?: string; scope_doc_path?: string; criteria?: unknown[] }
|
|
401
|
-
export interface PostVersionsResponse { version: unknown; criteria: unknown }
|
|
400
|
+
export interface PostVersionsRequest { id: string; name: string; status?: string; track?: string; done_when?: string; scope_doc_path?: string; criteria?: unknown[]; first_goal?: Record<string, unknown> }
|
|
401
|
+
export interface PostVersionsResponse { version: unknown; goal: unknown; criteria: unknown }
|
|
402
402
|
export interface PutGovernmentRanksRankKeyPermissionsPermissionKeyResponse { ok: boolean; rank_key: unknown; permission_key: unknown; granted: unknown }
|
|
403
403
|
export interface PutMingleOptInRequest { opted_in: boolean }
|
|
404
404
|
export interface PutMingleOptInResponse { opted_in: unknown; updated_at: unknown }
|
package/docs/api/openapi.json
CHANGED
|
@@ -12149,7 +12149,7 @@
|
|
|
12149
12149
|
"provisioning"
|
|
12150
12150
|
],
|
|
12151
12151
|
"summary": "PATCH /provisioning/instances/:id/settings",
|
|
12152
|
-
"description": "PATCH /provisioning/instances/:id/settings — change a project's settings. Body: any subset of the known keys; strict validation refuses unknown keys and off-vocabulary values. rank: any authenticated builder (own resource; archon may change any — the override is audited via the event's actor). The platform's row is updated first; then, for an ACTIVE instance on a shape the runner can reach, a 'settings-apply' intent is enqueued (task 1003140): the control-plane runner patches the instance's web.env in place and RESTARTS it, because the branding pack is memoized for the process lifetime. A save that changes nothing neither writes nor restarts. The response is a request receipt — `saved` is what the platform now stores, and `push` { queued, restart, reachable, message } says whether a restart was queued and when the value takes effect — never a claim the instance already runs it; a client reads that back from the instance's own public GET /instance manifest (brand.project). 409 intent_conflict (details.recorded: true) when the open slot is held by other work — the change IS saved.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
12152
|
+
"description": "PATCH /provisioning/instances/:id/settings — change a project's settings. Body: any subset of the known keys; strict validation refuses unknown keys and off-vocabulary values. rank: any authenticated builder (own resource; archon may change any — the override is audited via the event's actor). The platform's row is updated first; then, for an ACTIVE instance on a shape the runner can reach, a 'settings-apply' intent is enqueued (task 1003140): the control-plane runner patches the instance's web.env in place and RESTARTS it, because the branding pack is memoized for the process lifetime. A save that changes nothing neither writes nor restarts. The response is a request receipt — `saved` is what the platform now stores, and `push` { queued, restart, reachable, message } says whether a restart was queued and when the value takes effect — never a claim the instance already runs it; a client reads that back from the instance's own public GET /instance manifest (brand.project). 409 intent_conflict (details.recorded: true) when the open slot is held by other work — the change IS saved. A save whose push did not get carried (that 409, or an enqueue that threw) marks the push OWED, which is what makes the \"send it again\" both of those name actually re-queue instead of hitting the no-op \"No change\" branch (task 1003524).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
12153
12153
|
"x-rank": "any-builder",
|
|
12154
12154
|
"x-source": "modules/provisioning/routes/provisioning.js",
|
|
12155
12155
|
"parameters": [
|
|
@@ -22791,6 +22791,9 @@
|
|
|
22791
22791
|
"criteria": {
|
|
22792
22792
|
"type": "array",
|
|
22793
22793
|
"maxItems": 100
|
|
22794
|
+
},
|
|
22795
|
+
"first_goal": {
|
|
22796
|
+
"type": "object"
|
|
22794
22797
|
}
|
|
22795
22798
|
},
|
|
22796
22799
|
"required": [
|
|
@@ -22803,10 +22806,12 @@
|
|
|
22803
22806
|
"type": "object",
|
|
22804
22807
|
"properties": {
|
|
22805
22808
|
"version": {},
|
|
22809
|
+
"goal": {},
|
|
22806
22810
|
"criteria": {}
|
|
22807
22811
|
},
|
|
22808
22812
|
"required": [
|
|
22809
22813
|
"version",
|
|
22814
|
+
"goal",
|
|
22810
22815
|
"criteria"
|
|
22811
22816
|
]
|
|
22812
22817
|
},
|
package/docs/api-reference.md
CHANGED
|
@@ -629,7 +629,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
629
629
|
| Method | Path | Rank | Body | Description |
|
|
630
630
|
|---|---|---|---|---|
|
|
631
631
|
| GET | `/api/bongos/versions` | `public` | — | read-only version list; mirrors /public/versions. |
|
|
632
|
-
| POST | `/api/bongos/versions` | `archon` | `id`, `name`, `status`, `track`, `done_when`, `scope_doc_path`, `criteria` | POST /versions — create a version + its done-when criteria in one call. |
|
|
632
|
+
| POST | `/api/bongos/versions` | `archon` | `id`, `name`, `status`, `track`, `done_when`, `scope_doc_path`, `criteria`, `first_goal` | POST /versions — create a version + its done-when criteria in one call. |
|
|
633
633
|
| POST | `/api/bongos/versions/:id/close` | `archon` | `reason`, `dispositions` | POST /versions/:id/close — the move a version has never had (BV1.R12, task 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5). |
|
|
634
634
|
| GET | `/api/bongos/versions/:id/done-when` | `public` | — | Public: the status dashboard (status.<apex>) fetches this every 60s alongside other public/* surfaces. |
|
|
635
635
|
| POST | `/api/bongos/versions/:id/done-when` | `metic+archon` | `criterion_id`, `criterion_md`, `goal_id`, `sort_order` | Auth (Metic+, then the goal's own authority): create a done-when criterion at RUNTIME. |
|
|
@@ -1811,5 +1811,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1811
1811
|
landed since 1.19.674 with no explicit bump. run 34639371461. (task 1002620)
|
|
1812
1812
|
1.19.676 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1813
1813
|
landed since 1.19.675 with no explicit bump. run 34645751352. (task 1002620)
|
|
1814
|
+
1.19.677 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1815
|
+
landed since 1.19.676 with no explicit bump. run 34650610723. (task 1002620)
|
|
1816
|
+
1.19.678 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1817
|
+
landed since 1.19.677 with no explicit bump. run 34653458123. (task 1002620)
|
|
1814
1818
|
---------------------------------------------------------------------------
|
|
1815
1819
|
```
|
|
@@ -65,11 +65,17 @@ const MEMBERSHIP_REQUEST_COLS =
|
|
|
65
65
|
// state R03 exists to prevent, and a second round-trip can fail after the goal
|
|
66
66
|
// lands. Pass null (the normal path) and no admission row is written at all, so
|
|
67
67
|
// the presence of a row always means the gate was actually overridden.
|
|
68
|
+
//
|
|
69
|
+
// IT CAN RUN INSIDE THE CALLER'S TRANSACTION (deps.client), the same seam
|
|
70
|
+
// ensureMaintenanceGoal below already takes and for the same reason: a version's
|
|
71
|
+
// FIRST goal is created by db.createVersion in the transaction that creates the
|
|
72
|
+
// version (task 1003701), and a goal committed on its own connection would
|
|
73
|
+
// survive a rolled-back version create as a goal pointing at nothing.
|
|
68
74
|
async function createGoal(
|
|
69
75
|
{ versionId, title, subtitle = null, description = null, scopeModules = [], status = 'open', createdBy = null, categoryId = null, admission = null },
|
|
70
76
|
deps = {}
|
|
71
77
|
) {
|
|
72
|
-
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
78
|
+
const activePool = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
|
|
73
79
|
const { rows } = await activePool.query(
|
|
74
80
|
`WITH next_order AS (
|
|
75
81
|
SELECT COALESCE(MAX(sort_order) + 1, 0) AS n FROM goals WHERE version_id = $1
|
|
@@ -15,6 +15,10 @@ const { pool, withTx } = api;
|
|
|
15
15
|
const doneWhen = require('./done-when.js');
|
|
16
16
|
const { nonTerminalSql } = require('./task-status');
|
|
17
17
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
18
|
+
// db-goals.js does NOT require this file, so this direction adds no require cycle
|
|
19
|
+
// (fitness pins the count). createVersion needs it to seed a version's FIRST goal
|
|
20
|
+
// in the same transaction — task 1003701.
|
|
21
|
+
const { createGoal } = require('./db-goals.js');
|
|
18
22
|
|
|
19
23
|
async function listVersions() {
|
|
20
24
|
const { rows } = await pool.query(
|
|
@@ -130,10 +134,35 @@ async function versionsWithStatus(status, deps = {}) {
|
|
|
130
134
|
return rows;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
|
|
137
|
+
// createVersion — a version, its FIRST GOAL, and its done-when criteria, in one
|
|
138
|
+
// transaction (task 1003701).
|
|
139
|
+
//
|
|
140
|
+
// THE GOAL IS NOT OPTIONAL DECORATION; it closes a chicken-and-egg that made a
|
|
141
|
+
// fresh instance unseedable. `bongos init` creates its version at 'building', and
|
|
142
|
+
// two rules then met in the middle: BV1.R11 refuses a task with no goal_id, and
|
|
143
|
+
// R03 refuses a new goal on a version that is no longer 'planning'. So init could
|
|
144
|
+
// file no task, and could create no goal to file them under — proved against live
|
|
145
|
+
// on 2026-09-07. Creating the version's own first goal WITH the version is the
|
|
146
|
+
// way out that spends no escape hatch: this is not scope GROWTH on a closed
|
|
147
|
+
// version (what R03 guards), it is the version's initial scope, and it exists
|
|
148
|
+
// before the version is visible to anyone.
|
|
149
|
+
//
|
|
150
|
+
// It also fixes the quieter defect underneath. Since ADR 0086 criteria live under
|
|
151
|
+
// goals, and since migration 160 the catch-all goals were created by a one-shot
|
|
152
|
+
// backfill with no trigger behind it — so every version created afterwards got
|
|
153
|
+
// goal-less criteria, and ADR 0183 criteria satisfy themselves off LINKED tasks.
|
|
154
|
+
// Criteria nobody can link a task to can never close. Stamping goal_id here is
|
|
155
|
+
// what gives them something to hang from.
|
|
156
|
+
async function createVersion({ id, name, status = 'planning', track = 'product', scopeDocPath = null, doneWhen = null, criteria = [], firstGoal = null, createdBy = null }, deps = {}) {
|
|
134
157
|
// versions.track is NOT NULL with no DB default (migration 003) — omitting it
|
|
135
158
|
// here made every create 500. An instance's first version is its product
|
|
136
159
|
// track, so that's the default; 'internal' is the platform's own track.
|
|
160
|
+
//
|
|
161
|
+
// deps.pool is the module's existing test seam (listVersions, versionProgress):
|
|
162
|
+
// honoured ONLY under NODE_ENV=test, so production can never be pointed at an
|
|
163
|
+
// injected pool. It is threaded into withTx so the whole transaction — version,
|
|
164
|
+
// goal and criteria — runs on it.
|
|
165
|
+
const txPool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
137
166
|
return withTx(async (client) => {
|
|
138
167
|
const { rows } = await client.query(
|
|
139
168
|
`INSERT INTO versions (id, name, status, track, scope_doc_path, done_when)
|
|
@@ -142,21 +171,32 @@ async function createVersion({ id, name, status = 'planning', track = 'product',
|
|
|
142
171
|
[id, name, status, track, scopeDocPath, doneWhen]
|
|
143
172
|
);
|
|
144
173
|
const version = rows[0];
|
|
174
|
+
// The first goal. Titled for the version it opens unless the caller names one —
|
|
175
|
+
// a version's opening scope is the version, until someone splits it. Rides the
|
|
176
|
+
// SAME client, so a failure anywhere below rolls the goal back with everything
|
|
177
|
+
// else and never leaves a goal pointing at a version that does not exist.
|
|
178
|
+
const goal = await createGoal({
|
|
179
|
+
versionId: id,
|
|
180
|
+
title: (firstGoal && String(firstGoal.title || '').trim()) || name || id,
|
|
181
|
+
description: (firstGoal && firstGoal.description) || doneWhen || null,
|
|
182
|
+
createdBy,
|
|
183
|
+
}, { client });
|
|
145
184
|
const createdCriteria = [];
|
|
146
185
|
let i = 0;
|
|
147
186
|
for (const c of (criteria || [])) {
|
|
148
187
|
const { rows: cr } = await client.query(
|
|
149
|
-
`INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, sort_order)
|
|
150
|
-
VALUES ($1, $2, $3, $4)
|
|
188
|
+
`INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, sort_order, goal_id)
|
|
189
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
151
190
|
ON CONFLICT (version_id, criterion_id) DO NOTHING
|
|
152
|
-
RETURNING id, version_id, criterion_id, criterion_md, sort_order, satisfied`,
|
|
153
|
-
[id, c.criterion_id, c.criterion_md, Number.isFinite(c.sort_order) ? c.sort_order : (i + 1) * 10
|
|
191
|
+
RETURNING id, version_id, criterion_id, criterion_md, sort_order, satisfied, goal_id`,
|
|
192
|
+
[id, c.criterion_id, c.criterion_md, Number.isFinite(c.sort_order) ? c.sort_order : (i + 1) * 10,
|
|
193
|
+
goal ? goal.id : null]
|
|
154
194
|
);
|
|
155
195
|
if (cr[0]) createdCriteria.push(cr[0]);
|
|
156
196
|
i++;
|
|
157
197
|
}
|
|
158
|
-
return { version, criteria: createdCriteria };
|
|
159
|
-
});
|
|
198
|
+
return { version, goal: goal || null, criteria: createdCriteria };
|
|
199
|
+
}, { pool: txPool });
|
|
160
200
|
}
|
|
161
201
|
|
|
162
202
|
// currentBuildingVersionId — the version new work defaults into, or null when
|
|
@@ -103,6 +103,8 @@ module.exports = function buildVersionsRouter() {
|
|
|
103
103
|
done_when: { type: 'string', maxLength: 8000 },
|
|
104
104
|
scope_doc_path: { type: 'string', maxLength: 256 },
|
|
105
105
|
criteria: { type: 'array', maxItems: 100 },
|
|
106
|
+
// The version's first goal (task 1003701) — { title?, description? }, both optional.
|
|
107
|
+
first_goal: { type: 'object' },
|
|
106
108
|
})) return;
|
|
107
109
|
const body = req.body || {};
|
|
108
110
|
const id = String(body.id).trim();
|
|
@@ -177,13 +179,23 @@ module.exports = function buildVersionsRouter() {
|
|
|
177
179
|
});
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
|
|
182
|
+
// The version's FIRST GOAL (task 1003701). Optional and free-form: name it and
|
|
183
|
+
// the version opens with that goal, omit it and the goal carries the version's
|
|
184
|
+
// own name. It is created in createVersion's transaction, not here, because a
|
|
185
|
+
// goal that outlived a rolled-back version create would point at nothing.
|
|
186
|
+
const fg = body.first_goal && typeof body.first_goal === 'object' ? body.first_goal : null;
|
|
187
|
+
let version, goal, created;
|
|
181
188
|
try {
|
|
182
|
-
({ version, criteria: created } = await db.createVersion({
|
|
189
|
+
({ version, goal, criteria: created } = await db.createVersion({
|
|
183
190
|
id, name, status, track,
|
|
184
191
|
doneWhen: body.done_when ?? null,
|
|
185
192
|
scopeDocPath: body.scope_doc_path ?? null,
|
|
186
193
|
criteria,
|
|
194
|
+
firstGoal: fg,
|
|
195
|
+
// The creator OWNS the goal (createGoal's rule — created_by plus a 'lead'
|
|
196
|
+
// member row). Null for an unauthenticated/system create, which is the
|
|
197
|
+
// migration-160 posture: no owner, Archons act as one.
|
|
198
|
+
createdBy: (req.builder && req.builder.id) || null,
|
|
187
199
|
}));
|
|
188
200
|
} catch (err) {
|
|
189
201
|
// TOCTOU: the getVersion() pre-check above runs outside the insert
|
|
@@ -222,7 +234,11 @@ module.exports = function buildVersionsRouter() {
|
|
|
222
234
|
}
|
|
223
235
|
throw err;
|
|
224
236
|
}
|
|
225
|
-
|
|
237
|
+
// `goal` is returned because a caller that just made a version CANNOT file a
|
|
238
|
+
// task without one (BV1.R11) and cannot create one afterwards (R03) — so the
|
|
239
|
+
// id has to come back with the version or the caller is stuck. This is the
|
|
240
|
+
// half `bongos init` was missing (task 1003701).
|
|
241
|
+
res.status(201).json({ version, goal: goal || null, criteria: created });
|
|
226
242
|
}, { errorCode: 'create_version_failed' }));
|
|
227
243
|
|
|
228
244
|
|
|
@@ -441,6 +441,12 @@ ${lines}
|
|
|
441
441
|
`;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
// The reserved settings-jsonb key settingsPushOwed/recordSettingsPushOwed use
|
|
445
|
+
// (task 1003524). Deliberately NOT in SETTINGS_VOCAB or PLANET_VOCAB — it is the
|
|
446
|
+
// platform's own bookkeeping, not a setting an owner declares, and the test
|
|
447
|
+
// asserts it stays out of both so it can never start projecting as one.
|
|
448
|
+
const SETTINGS_PUSH_OWED_KEY = 'push_owed';
|
|
449
|
+
|
|
444
450
|
// The instance's EFFECTIVE settings: the defaults overlaid with the stored,
|
|
445
451
|
// on-vocabulary keys. An off-vocabulary or unknown stored value falls back to
|
|
446
452
|
// its default rather than leaking into the read surface. PURE.
|
|
@@ -466,6 +472,30 @@ function planetChoice(row) {
|
|
|
466
472
|
return out;
|
|
467
473
|
}
|
|
468
474
|
|
|
475
|
+
// Is a POLICY settings push OWED to this instance (task 1003524)? Set when the
|
|
476
|
+
// row was written but its push demonstrably did not get carried: the open slot
|
|
477
|
+
// was held by other work (409 intent_conflict), or the enqueue itself threw.
|
|
478
|
+
// Both of those answer the owner "send it again" — and without this marker that
|
|
479
|
+
// remedy is a NO-OP, because the re-send is by definition unchanged and takes
|
|
480
|
+
// the "No change" branch, leaving the saved value stranded on the platform. (The
|
|
481
|
+
// only way out was toggling to a third value and back.)
|
|
482
|
+
//
|
|
483
|
+
// It lives as a RESERVED key in the settings jsonb rather than a column so it
|
|
484
|
+
// costs no migration — the provisioning_009 rationale, which is why that column
|
|
485
|
+
// is jsonb in the first place. It can neither leak nor be client-written:
|
|
486
|
+
// effectiveSettings / planetChoice / settingsEnvVars project ONLY vocabulary
|
|
487
|
+
// keys, and the route builds its patch only from settingKeys.
|
|
488
|
+
//
|
|
489
|
+
// error_note is the sibling marker and the two do not overlap: that one latches
|
|
490
|
+
// a push that WAS queued and then failed (F19), this one a push that was never
|
|
491
|
+
// queued at all. Together they make "send it again" true on every branch that
|
|
492
|
+
// says it. PURE.
|
|
493
|
+
function settingsPushOwed(row) {
|
|
494
|
+
const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
|
|
495
|
+
? row.settings : {};
|
|
496
|
+
return stored[SETTINGS_PUSH_OWED_KEY] === true;
|
|
497
|
+
}
|
|
498
|
+
|
|
469
499
|
// A description as it may be STORED: trimmed, collapsed of runaway whitespace,
|
|
470
500
|
// capped, and empty-means-absent. Returns null for anything that isn't real prose,
|
|
471
501
|
// because null is what leaves the project dark-matter — an empty string stored in a
|
|
@@ -669,6 +699,25 @@ async function updateInstanceSettings(db, id, patch) {
|
|
|
669
699
|
return rows[0] || null;
|
|
670
700
|
}
|
|
671
701
|
|
|
702
|
+
// Mark (or clear) the owed-push marker settingsPushOwed reads (task 1003524).
|
|
703
|
+
// Same narrowness rule as recordDnsNote / clearErrorNote: no `status`, and no
|
|
704
|
+
// `updated_at` — this is the platform's own bookkeeping, and bumping updated_at
|
|
705
|
+
// would reshuffle the owner's card ordering (listInstancesForOwner orders by it)
|
|
706
|
+
// for a write the owner did not make. Clearing DELETES the key rather than
|
|
707
|
+
// storing false, so a row that never owed a push is byte-identical to one whose
|
|
708
|
+
// debt was settled.
|
|
709
|
+
async function recordSettingsPushOwed(db, id, owed) {
|
|
710
|
+
const { rows } = await db.query(
|
|
711
|
+
`UPDATE provisioning_instances
|
|
712
|
+
SET settings = CASE WHEN $2::boolean
|
|
713
|
+
THEN settings || $3::jsonb
|
|
714
|
+
ELSE settings - $4::text END
|
|
715
|
+
WHERE id = $1 RETURNING id, settings`,
|
|
716
|
+
[id, !!owed, JSON.stringify({ [SETTINGS_PUSH_OWED_KEY]: true }), SETTINGS_PUSH_OWED_KEY]
|
|
717
|
+
);
|
|
718
|
+
return rows[0] || null;
|
|
719
|
+
}
|
|
720
|
+
|
|
672
721
|
// Fill in (or change) the project-detail answers AFTER creation — the other half
|
|
673
722
|
// of "skippable" (task 1002334). Skipping the step at creation is a supported
|
|
674
723
|
// default, but it is only a default and not a dead end if there is a way back; this
|
|
@@ -1290,6 +1339,7 @@ module.exports = {
|
|
|
1290
1339
|
// pure
|
|
1291
1340
|
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
1292
1341
|
defaultDomainForSlug, isReservedHostname, effectiveSettings, planetChoice,
|
|
1342
|
+
settingsPushOwed,
|
|
1293
1343
|
normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
|
|
1294
1344
|
// pure — GitHub App Manifest flow (task 2080)
|
|
1295
1345
|
githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
|
|
@@ -1298,6 +1348,8 @@ module.exports = {
|
|
|
1298
1348
|
getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
|
|
1299
1349
|
// data access — verification overlay (task 1002696)
|
|
1300
1350
|
listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
|
|
1351
|
+
// data access — the owed-push marker (task 1003524)
|
|
1352
|
+
recordSettingsPushOwed,
|
|
1301
1353
|
createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
|
|
1302
1354
|
createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
|
|
1303
1355
|
enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
|
|
@@ -853,7 +853,10 @@ module.exports = function provisioningRoutes() {
|
|
|
853
853
|
// and when the value takes effect — never a claim the instance already runs
|
|
854
854
|
// it; a client reads that back from the instance's own public GET /instance
|
|
855
855
|
// manifest (brand.project). 409 intent_conflict (details.recorded: true) when
|
|
856
|
-
// the open slot is held by other work — the change IS saved.
|
|
856
|
+
// the open slot is held by other work — the change IS saved. A save whose push
|
|
857
|
+
// did not get carried (that 409, or an enqueue that threw) marks the push OWED,
|
|
858
|
+
// which is what makes the "send it again" both of those name actually re-queue
|
|
859
|
+
// instead of hitting the no-op "No change" branch (task 1003524).
|
|
857
860
|
router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
|
|
858
861
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
859
862
|
if (!id) return;
|
|
@@ -898,7 +901,13 @@ module.exports = function provisioningRoutes() {
|
|
|
898
901
|
const policyKeys = Object.keys(provisioning.SETTINGS_VOCAB);
|
|
899
902
|
const policyTouched = policyKeys.some((k) => patch[k] !== undefined);
|
|
900
903
|
const policyChanged = policyKeys.some((k) => patch[k] !== undefined && before[k] !== patch[k]);
|
|
901
|
-
|
|
904
|
+
// A push is OWED when the last save's push did not get carried — a queued
|
|
905
|
+
// push that RAN and failed (error_note, the F19 re-kick), or one never queued
|
|
906
|
+
// at all (the 409 below, or an enqueue that threw; task 1003524). Both answer
|
|
907
|
+
// "send it again", and folding the second in is what makes that sentence
|
|
908
|
+
// true: the re-send is unchanged, so it would otherwise hit "No change".
|
|
909
|
+
const pushOwed = provisioning.settingsPushOwed(inst);
|
|
910
|
+
const policyResent = (!!inst.error_note || pushOwed) && policyTouched;
|
|
902
911
|
const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
|
|
903
912
|
if (changed) {
|
|
904
913
|
await provisioning.recordEvent(pool, {
|
|
@@ -947,14 +956,25 @@ module.exports = function provisioningRoutes() {
|
|
|
947
956
|
catch (err) {
|
|
948
957
|
// The value IS saved — say so; a 500 here would deny a durable write.
|
|
949
958
|
log.error('[provisioning] PATCH /provisioning/instances/:id/settings enqueue', err);
|
|
959
|
+
// The remedy this sentence names has to WORK (task 1003524): nothing is
|
|
960
|
+
// going to carry the saved value, so record the debt. Best-effort — a failed
|
|
961
|
+
// marker write must never turn a durable settings write into a 500.
|
|
962
|
+
await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
|
|
950
963
|
push = receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' });
|
|
951
964
|
}
|
|
952
965
|
if (r && r.conflict) {
|
|
966
|
+
// Saved, but the slot is held by work that will not carry this change, so
|
|
967
|
+
// "send it again once that settles" has to re-queue when they do (task 1003524).
|
|
968
|
+
await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
|
|
953
969
|
return res.fail('intent_conflict', { status: 409,
|
|
954
970
|
message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
|
|
955
971
|
details: { action: r.intent.action, state: r.intent.state, recorded: true } });
|
|
956
972
|
}
|
|
957
973
|
if (r) {
|
|
974
|
+
// A push is now in hand — fresh, or a PENDING one that has not read the row
|
|
975
|
+
// yet — so either way it carries the current settings. Only written when
|
|
976
|
+
// there IS a debt, so the hot path stays a single UPDATE.
|
|
977
|
+
if (pushOwed) await provisioning.recordSettingsPushOwed(pool, id, false).catch(() => {});
|
|
958
978
|
push = receipt({ queued: r.created, restart: true, message: r.created
|
|
959
979
|
? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
|
|
960
980
|
: 'Saved. A restart is already queued for this project and will carry this change.' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.678",
|
|
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.678",
|
|
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.678",
|
|
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/scripts/gds/init.js
CHANGED
|
@@ -668,8 +668,30 @@ async function seedViaApi(spec, { dryRun, log = console.log, api = apiCall } = {
|
|
|
668
668
|
if (!vres.ok && vres.status !== 409) {
|
|
669
669
|
throw new Error(`POST /versions failed (${vres.status}): ${JSON.stringify(vres.data)}`);
|
|
670
670
|
}
|
|
671
|
-
|
|
672
|
-
|
|
671
|
+
// The version's first goal — every task below needs its id (BV1.R11 refuses a
|
|
672
|
+
// goal-less task, and R03 refuses creating a goal on a version that is already
|
|
673
|
+
// 'building', which is what versionPayload makes). POST /versions now returns it
|
|
674
|
+
// alongside the version (task 1003701); on the 409 re-run path the version is
|
|
675
|
+
// already there, so read its goals back instead.
|
|
676
|
+
let goalId = vres.ok && vres.data && vres.data.goal ? vres.data.goal.id : null;
|
|
677
|
+
if (vres.status === 409) {
|
|
678
|
+
log(` • version ${vp.id} already exists — continuing.`);
|
|
679
|
+
const gres = await api('GET', `/api/gds/goals?version=${encodeURIComponent(vp.id)}`);
|
|
680
|
+
const goals = gres.ok ? (Array.isArray(gres.data) ? gres.data : (gres.data && gres.data.goals) || []) : [];
|
|
681
|
+
const open = goals.find((g) => g && g.status === 'open' && !g.is_maintenance) || goals.find((g) => g && g.status === 'open');
|
|
682
|
+
goalId = open ? open.id : null;
|
|
683
|
+
} else {
|
|
684
|
+
log(` ✓ created version ${vp.id} + its first goal`);
|
|
685
|
+
}
|
|
686
|
+
// Fail LOUD rather than filing a wave of tasks the server will refuse one by one:
|
|
687
|
+
// with no goal every POST below returns 400 goal_id_required, and the run would
|
|
688
|
+
// report a pile of identical failures instead of the single reason behind them.
|
|
689
|
+
if (goalId == null) {
|
|
690
|
+
throw new Error(
|
|
691
|
+
`could not determine a goal for version ${vp.id} — every task needs one (goal_id_required). ` +
|
|
692
|
+
`The version exists but has no open goal to file under; create one and re-run.`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
673
695
|
const criterionId = vp.criteria[0].criterion_id;
|
|
674
696
|
|
|
675
697
|
// A tracked kickoff/starter task (status ready). Returns true on success.
|
|
@@ -681,7 +703,7 @@ async function seedViaApi(spec, { dryRun, log = console.log, api = apiCall } = {
|
|
|
681
703
|
const postTask = async (t, extra = {}) => {
|
|
682
704
|
attempted++;
|
|
683
705
|
const res = await api('POST', '/api/gds/tasks', {
|
|
684
|
-
version_id: vp.id, title: t.title, description: t.description || '', status: 'ready',
|
|
706
|
+
version_id: vp.id, goal_id: goalId, title: t.title, description: t.description || '', status: 'ready',
|
|
685
707
|
kind: t.kind || 'infra', discipline: t.discipline || 'engineer', source: 'bongos-init', ...extra,
|
|
686
708
|
});
|
|
687
709
|
if (!res.ok) failures.push({ title: t.title, status: res.status, detail: JSON.stringify(res.data) });
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.678'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/init.mjs
CHANGED
|
@@ -558,11 +558,21 @@ test('writeConfigs (greenfield, dry-run): mints no README', () => {
|
|
|
558
558
|
// A fake apiCall that records calls and returns a configured response per path
|
|
559
559
|
// substring (default: 200 ok). Lets us exercise the live seedViaApi branching
|
|
560
560
|
// (POST the request; 404/error → fall back to the manual task) with no server.
|
|
561
|
+
// POST /versions answers with the version's FIRST GOAL since task 1003701, and
|
|
562
|
+
// seedViaApi cannot file a single task without its id (BV1.R11 refuses a task with
|
|
563
|
+
// no goal_id, and R03 refuses creating one on a version that is already
|
|
564
|
+
// 'building'). So the default version response carries one — a stub that omitted
|
|
565
|
+
// it would not be a simpler fixture, it would be a server that cannot be seeded,
|
|
566
|
+
// which is the bug this all fixes. FAKE_GOAL_ID is asserted on the task POSTs.
|
|
567
|
+
const FAKE_GOAL_ID = 4242;
|
|
561
568
|
function fakeApi(responses = []) {
|
|
562
569
|
const calls = [];
|
|
563
570
|
const api = async (method, path, body) => {
|
|
564
571
|
calls.push({ method, path, body });
|
|
565
572
|
for (const [match, res] of responses) if (path.includes(match)) return res;
|
|
573
|
+
if (path.endsWith('/versions')) {
|
|
574
|
+
return { ok: true, status: 201, data: { version: { id: (body && body.id) || 'V1' }, goal: { id: FAKE_GOAL_ID, status: 'open' }, criteria: [] } };
|
|
575
|
+
}
|
|
566
576
|
return { ok: true, status: 200, data: {} };
|
|
567
577
|
};
|
|
568
578
|
api.calls = calls;
|
|
@@ -623,6 +633,9 @@ async function runSeedTests() {
|
|
|
623
633
|
const calls = [];
|
|
624
634
|
const api = async (method, path, body) => {
|
|
625
635
|
calls.push({ method, path, body });
|
|
636
|
+
if (path.endsWith('/versions')) {
|
|
637
|
+
return { ok: true, status: 201, data: { version: { id: body.id }, goal: { id: FAKE_GOAL_ID, status: 'open' }, criteria: [] } };
|
|
638
|
+
}
|
|
626
639
|
if (path.endsWith('/tasks') && /instance look/i.test((body && body.title) || '')) {
|
|
627
640
|
return { ok: false, status: 400, data: { error: 'validation_failed' } };
|
|
628
641
|
}
|
|
@@ -639,6 +652,61 @@ async function runSeedTests() {
|
|
|
639
652
|
assert.match(text, new RegExp(`⚠ filed ${out.created} of ${out.attempted}`), 'summary is a warning with actual-vs-expected counts, not a green check');
|
|
640
653
|
});
|
|
641
654
|
|
|
655
|
+
// ── the version's first goal (task 1003701) ──────────────────────────────
|
|
656
|
+
// `bongos init` could not seed a new instance at all: versionPayload creates the
|
|
657
|
+
// version at 'building', BV1.R11 refuses a task with no goal_id, and R03 refuses
|
|
658
|
+
// creating a goal on a version that is no longer 'planning'. Both halves were
|
|
659
|
+
// proved against live on 2026-09-07 — every starter and kickoff task 400'd.
|
|
660
|
+
|
|
661
|
+
await at('seedViaApi: EVERY task POST carries the goal_id the version create returned', async () => {
|
|
662
|
+
const api = fakeApi();
|
|
663
|
+
const out = await init.seedViaApi(provSpec({}), { log: () => {}, api });
|
|
664
|
+
const taskPosts = api.calls.filter((c) => c.path.endsWith('/tasks'));
|
|
665
|
+
assert.ok(taskPosts.length > 0, 'there are tasks to check');
|
|
666
|
+
for (const c of taskPosts) {
|
|
667
|
+
assert.equal(c.body.goal_id, FAKE_GOAL_ID, `${c.body.title} filed under the version's first goal`);
|
|
668
|
+
}
|
|
669
|
+
assert.equal(out.failures.length, 0, 'and none of them is refused');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
await at('seedViaApi: a version create that answers with NO goal fails loudly, before filing anything', async () => {
|
|
673
|
+
// Fail closed and say the real reason ONCE. Without this every task POST comes
|
|
674
|
+
// back 400 goal_id_required and the run reports a pile of identical failures
|
|
675
|
+
// instead of the single cause behind them.
|
|
676
|
+
const api = fakeApi([['/versions', { ok: true, status: 201, data: { version: { id: 'V1' }, criteria: [] } }]]);
|
|
677
|
+
await assert.rejects(
|
|
678
|
+
() => init.seedViaApi(provSpec({}), { log: () => {}, api }),
|
|
679
|
+
/goal/i,
|
|
680
|
+
'the failure names the goal, not a task'
|
|
681
|
+
);
|
|
682
|
+
assert.equal(api.calls.filter((c) => c.path.endsWith('/tasks')).length, 0, 'nothing was filed');
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
await at('seedViaApi: on a 409 re-run it reads the goal back, preferring an ordinary open goal over the maintenance one', async () => {
|
|
686
|
+
const api = fakeApi([
|
|
687
|
+
['/versions', { ok: false, status: 409, data: { error: 'version_exists' } }],
|
|
688
|
+
['/goals', { ok: true, status: 200, data: { goals: [
|
|
689
|
+
{ id: 900, status: 'open', is_maintenance: true },
|
|
690
|
+
{ id: 901, status: 'achieved', is_maintenance: false },
|
|
691
|
+
{ id: 902, status: 'open', is_maintenance: false },
|
|
692
|
+
] } }],
|
|
693
|
+
]);
|
|
694
|
+
const out = await init.seedViaApi(provSpec({}), { log: () => {}, api });
|
|
695
|
+
const taskPosts = api.calls.filter((c) => c.path.endsWith('/tasks'));
|
|
696
|
+
assert.ok(taskPosts.length > 0);
|
|
697
|
+
for (const c of taskPosts) assert.equal(c.body.goal_id, 902, 'the ordinary open goal, not the maintenance bucket and not the achieved one');
|
|
698
|
+
assert.equal(out.failures.length, 0);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
await at('seedViaApi: a 409 re-run on a version with no open goal fails loudly too', async () => {
|
|
702
|
+
const api = fakeApi([
|
|
703
|
+
['/versions', { ok: false, status: 409, data: { error: 'version_exists' } }],
|
|
704
|
+
['/goals', { ok: true, status: 200, data: { goals: [{ id: 900, status: 'archived', is_maintenance: false }] } }],
|
|
705
|
+
]);
|
|
706
|
+
await assert.rejects(() => init.seedViaApi(provSpec({}), { log: () => {}, api }), /goal/i);
|
|
707
|
+
assert.equal(api.calls.filter((c) => c.path.endsWith('/tasks')).length, 0, 'nothing was filed');
|
|
708
|
+
});
|
|
709
|
+
|
|
642
710
|
await at('seedViaApi: no provisioning block → no provisioning POST, topo stays a normal kickoff task', async () => {
|
|
643
711
|
const spec = init.normalizeSpec({ identity: { productName: 'X Co' }, repo: { owner: 'o', name: 'n' },
|
|
644
712
|
firstAdmin: 'x', version: { id: 'V1', name: 'V1', done_when: 'ship' } });
|
|
@@ -132,7 +132,19 @@ let enqueueCalls = [];
|
|
|
132
132
|
let enqueueResult = null; // what enqueueSettingsApply reports (null → created:true)
|
|
133
133
|
let enqueueThrows = null; // an Error to throw from enqueueSettingsApply
|
|
134
134
|
let events = [];
|
|
135
|
+
let owedCalls = []; // every recordSettingsPushOwed(id, owed) the route makes
|
|
135
136
|
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
137
|
+
// The marker is a reserved key in the SAME settings jsonb, so the stub mutates the
|
|
138
|
+
// fixture the way the real UPDATE does — that is what lets a test re-send against
|
|
139
|
+
// the row its own previous save left behind.
|
|
140
|
+
const realRecordSettingsPushOwed = provisioning.recordSettingsPushOwed;
|
|
141
|
+
provisioning.recordSettingsPushOwed = async (_db, id, owed) => {
|
|
142
|
+
owedCalls.push({ id, owed });
|
|
143
|
+
const next = { ...(instance.settings || {}) };
|
|
144
|
+
if (owed) next.push_owed = true; else delete next.push_owed;
|
|
145
|
+
instance = { ...instance, settings: next };
|
|
146
|
+
return { id, settings: next };
|
|
147
|
+
};
|
|
136
148
|
provisioning.updateInstanceSettings = async (_db, id, patch) => {
|
|
137
149
|
updateCalls.push({ id, patch, enqueuedSoFar: enqueueCalls.length });
|
|
138
150
|
instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
|
|
@@ -196,6 +208,7 @@ function reset({ inst, as } = {}) {
|
|
|
196
208
|
enqueueResult = null;
|
|
197
209
|
enqueueThrows = null;
|
|
198
210
|
events = [];
|
|
211
|
+
owedCalls = [];
|
|
199
212
|
}
|
|
200
213
|
// every receipt carries the same four keys, and never a duration promise
|
|
201
214
|
function receiptShape(push, { queued, restart, reachable }) {
|
|
@@ -354,6 +367,122 @@ await ta('ACTIVE dedicated: stored, NO intent, reachable:false, restart:true (a
|
|
|
354
367
|
assert.doesNotMatch(r.body.push.message, /web\.env/, 'no file the owner has never seen');
|
|
355
368
|
});
|
|
356
369
|
|
|
370
|
+
console.log("\nthe owed push — making \"send it again\" true (task 1003524):");
|
|
371
|
+
|
|
372
|
+
// THE HEADLINE, end to end, exactly as the bug was reported: the owner saves
|
|
373
|
+
// visibility (queued), saves joinability one card below while the first push is
|
|
374
|
+
// RUNNING (409), that run settles, and they do what the 409 told them to — re-send
|
|
375
|
+
// the SAME joinability. Before this fix the re-send was unchanged, so it took the
|
|
376
|
+
// "No change" branch and queued nothing: the value sat on the platform forever and
|
|
377
|
+
// the only way out was toggling to a third value and back.
|
|
378
|
+
await ta('save A queues → save B 409s while A RUNS → A settles → the UNCHANGED re-send of B queues a NEW push', async () => {
|
|
379
|
+
reset();
|
|
380
|
+
|
|
381
|
+
// 1. visibility: a clean queue.
|
|
382
|
+
let r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
383
|
+
assert.equal(r.status, 200, r.raw);
|
|
384
|
+
assert.equal(r.body.push.queued, true);
|
|
385
|
+
assert.equal(enqueueCalls.length, 1);
|
|
386
|
+
|
|
387
|
+
// 2. joinability, while that push is RUNNING — saved, but nothing carries it.
|
|
388
|
+
enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'running' }, created: false, conflict: 'open_intent' };
|
|
389
|
+
r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
390
|
+
assert.equal(r.status, 409, r.raw);
|
|
391
|
+
assert.equal(r.body.error.details.recorded, true, 'the value IS stored');
|
|
392
|
+
assert.match(r.body.error.message, /send it again once that settles/i, 'the remedy this test exists to make true');
|
|
393
|
+
assert.deepEqual(owedCalls, [{ id: 18, owed: true }], 'the debt is recorded at the moment the push is refused');
|
|
394
|
+
|
|
395
|
+
// 3. that run settles; the owner re-sends the SAME joinability.
|
|
396
|
+
enqueueResult = null; // the slot is free again
|
|
397
|
+
const enqueuedBefore = enqueueCalls.length;
|
|
398
|
+
const writesBefore = updateCalls.length;
|
|
399
|
+
r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
400
|
+
|
|
401
|
+
assert.equal(r.status, 200, r.raw);
|
|
402
|
+
assert.doesNotMatch(r.body.push.message, /No change/, 'the whole bug: the remedy used to be a no-op');
|
|
403
|
+
assert.equal(r.body.push.queued, true, 'a NEW push is queued');
|
|
404
|
+
assert.equal(enqueueCalls.length, enqueuedBefore + 1, 'exactly one new intent');
|
|
405
|
+
assert.equal(updateCalls.length, writesBefore, 'nothing re-written — the value was already stored in step 2');
|
|
406
|
+
assert.deepEqual(owedCalls[owedCalls.length - 1], { id: 18, owed: false }, 'and the debt is settled');
|
|
407
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full' },
|
|
408
|
+
'the value that finally ships is the one the owner saved');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await ta('an enqueue that THROWS records the debt too — its "send it again" is the same sentence', async () => {
|
|
412
|
+
reset();
|
|
413
|
+
enqueueThrows = new Error('db down');
|
|
414
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
415
|
+
assert.equal(r.status, 200, r.raw);
|
|
416
|
+
assert.match(r.body.push.message, /send it again/i);
|
|
417
|
+
assert.deepEqual(owedCalls, [{ id: 18, owed: true }]);
|
|
418
|
+
|
|
419
|
+
// and the re-send it names now works
|
|
420
|
+
enqueueThrows = null;
|
|
421
|
+
const r2 = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
422
|
+
assert.equal(r2.body.push.queued, true, 'the unchanged re-send queues');
|
|
423
|
+
assert.equal(updateCalls.length, 1, 'still only the original write');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await ta('a push already in hand settles the debt; with no debt outstanding nothing is written (the hot path stays one UPDATE)', async () => {
|
|
427
|
+
// an identical PENDING push (created:false, no conflict) has not read the row yet,
|
|
428
|
+
// so it carries this change — the debt is settled just as for a fresh intent.
|
|
429
|
+
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', push_owed: true } } });
|
|
430
|
+
enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'pending' }, created: false };
|
|
431
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
432
|
+
assert.equal(r.status, 200, r.raw);
|
|
433
|
+
assert.deepEqual(owedCalls, [{ id: 18, owed: false }], 'a pending push carries it — debt settled');
|
|
434
|
+
|
|
435
|
+
reset();
|
|
436
|
+
await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
437
|
+
assert.deepEqual(owedCalls, [], 'no debt, no bookkeeping write');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await ta('an owed push is a POLICY debt: a planet-only re-send neither re-kicks it nor clears it', async () => {
|
|
441
|
+
reset({ inst: { ...ACTIVE, settings: { planet_template: 'bead-obsidian-tint', push_owed: true } } });
|
|
442
|
+
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint' });
|
|
443
|
+
assert.equal(r.status, 200, r.raw);
|
|
444
|
+
assert.equal(enqueueCalls.length, 0, 'the planet reaches no instance, so it re-kicks nothing');
|
|
445
|
+
assert.deepEqual(owedCalls, [], 'and the policy debt survives, still owed');
|
|
446
|
+
assert.equal(provisioning.settingsPushOwed(instance), true);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await ta('the marker never leaks: not a vocabulary key, not in any projection, and a client cannot write it', async () => {
|
|
450
|
+
const row = { settings: { push_owed: true, joinability: 'open', planet_template: 'bead-obsidian-tint' } };
|
|
451
|
+
assert.equal(provisioning.settingsPushOwed(row), true);
|
|
452
|
+
assert.ok(!('push_owed' in provisioning.SETTINGS_VOCAB), 'not a policy setting');
|
|
453
|
+
assert.ok(!('push_owed' in provisioning.PLANET_VOCAB), 'not a planet setting');
|
|
454
|
+
assert.ok(!('push_owed' in provisioning.effectiveSettings(row)), 'never in the saved projection');
|
|
455
|
+
assert.ok(!('push_owed' in provisioning.planetChoice(row)), 'never in the planet projection');
|
|
456
|
+
const env = provisioning.settingsEnvVars(row, 'BONGOS');
|
|
457
|
+
for (const k of Object.keys(env)) assert.doesNotMatch(k, /PUSH_OWED/, 'never reaches the instance web.env');
|
|
458
|
+
assert.equal(provisioning.settingsPushOwed({ settings: {} }), false);
|
|
459
|
+
assert.equal(provisioning.settingsPushOwed({ settings: { push_owed: 'yes' } }), false, 'strictly true, never truthy');
|
|
460
|
+
assert.equal(provisioning.settingsPushOwed(null), false);
|
|
461
|
+
|
|
462
|
+
// strict validation refuses it on the way in, so it is server-only by construction
|
|
463
|
+
reset();
|
|
464
|
+
const r = await patch('/provisioning/instances/18/settings', { push_owed: true });
|
|
465
|
+
assert.equal(r.status, 400, r.raw);
|
|
466
|
+
assert.equal(updateCalls.length, 0);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
await ta('recordSettingsPushOwed writes NOTHING but the marker — no status, no updated_at, and clearing deletes the key', async () => {
|
|
470
|
+
const seen = [];
|
|
471
|
+
const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ id: 18, settings: {} }] }; } };
|
|
472
|
+
await realRecordSettingsPushOwed(db, 18, true);
|
|
473
|
+
await realRecordSettingsPushOwed(db, 18, false);
|
|
474
|
+
for (const c of seen) {
|
|
475
|
+
assert.match(c.sql, /UPDATE provisioning_instances/);
|
|
476
|
+
assert.doesNotMatch(c.sql, /updated_at/, 'bookkeeping must not reshuffle the owner\'s card order');
|
|
477
|
+
assert.doesNotMatch(c.sql, /status/, 'and must not touch the lifecycle');
|
|
478
|
+
assert.match(c.sql, /settings - \$4::text/, 'clearing DELETES the key rather than storing false');
|
|
479
|
+
assert.equal(c.params[0], 18);
|
|
480
|
+
}
|
|
481
|
+
assert.equal(seen[0].params[1], true);
|
|
482
|
+
assert.equal(seen[1].params[1], false);
|
|
483
|
+
assert.equal(seen[0].params[3], 'push_owed', 'the reserved key, passed as a parameter not interpolated');
|
|
484
|
+
});
|
|
485
|
+
|
|
357
486
|
console.log('\nthe walls that did not move:');
|
|
358
487
|
|
|
359
488
|
await ta("another builder's PATCH → 404; nothing stored, nothing queued", async () => {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// tests/version_first_goal.mjs
|
|
2
|
+
//
|
|
3
|
+
// A version is created WITH its first goal, and its done-when criteria hang off
|
|
4
|
+
// that goal (task 1003701, goal 1000106).
|
|
5
|
+
//
|
|
6
|
+
// THE BUG. `bongos init` could not seed a new instance at all. Two rules met in
|
|
7
|
+
// the middle, both proved against live cloudbongos.com on 2026-09-07:
|
|
8
|
+
// • BV1.R11 (task 1003598) — POST /tasks refuses a task with no goal_id, so
|
|
9
|
+
// every starter and kickoff task 400'd `goal_id_required`.
|
|
10
|
+
// • R03 (task 1003591) — POST /goals refuses a new goal on a version that is
|
|
11
|
+
// not 'planning', and versionPayload creates the version at 'building', so
|
|
12
|
+
// init could not create a goal to file them under either.
|
|
13
|
+
// There is no version-status transition route yet, so init could not create at
|
|
14
|
+
// 'planning', seed, and promote. The way out is the version's OWN first goal,
|
|
15
|
+
// created in the same transaction: that is not scope GROWTH on a closed version
|
|
16
|
+
// (what R03 guards) but the version's initial scope, and it spends none of the
|
|
17
|
+
// R07 Archon admission override that ADR 0250 §3 keeps counted.
|
|
18
|
+
//
|
|
19
|
+
// THE QUIETER DEFECT UNDERNEATH. Since ADR 0086 criteria live under goals, but
|
|
20
|
+
// createVersion inserted done_when_criteria with no goal_id — the catch-all goals
|
|
21
|
+
// came from migration 160's one-shot backfill with no trigger behind it, so every
|
|
22
|
+
// version created after it got goal-less criteria. Since ADR 0183 criteria satisfy
|
|
23
|
+
// themselves off LINKED tasks, a criterion nobody can link a task to can never
|
|
24
|
+
// close. Stamping goal_id here is what gives them something to hang from.
|
|
25
|
+
//
|
|
26
|
+
// DB-FREE, through the module's OWN test seam: createVersion takes deps.pool and
|
|
27
|
+
// honours it only under NODE_ENV=test (the listVersions / versionProgress idiom),
|
|
28
|
+
// threading it into withTx so version, goal and criteria all run on the fake. The
|
|
29
|
+
// fake client answers by statement shape and records every one, which is what lets
|
|
30
|
+
// this pin the SQL's shape AND its order without a Postgres — and the goal INSERT
|
|
31
|
+
// appearing inside the same BEGIN/COMMIT is the proof that createGoal rode the
|
|
32
|
+
// caller's transaction rather than opening its own connection. (The live SQL is
|
|
33
|
+
// exercised against a real database the same way the other write paths are — see
|
|
34
|
+
// the versions_route.mjs note.)
|
|
35
|
+
//
|
|
36
|
+
// Run: node tests/version_first_goal.mjs
|
|
37
|
+
|
|
38
|
+
import { strict as assert } from 'node:assert';
|
|
39
|
+
import { createRequire } from 'node:module';
|
|
40
|
+
|
|
41
|
+
const require = createRequire(import.meta.url);
|
|
42
|
+
|
|
43
|
+
let passed = 0, failed = 0;
|
|
44
|
+
async function t(name, fn) {
|
|
45
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
46
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── the fake connection ─────────────────────────────────────────────────────
|
|
50
|
+
// Answers each statement by shape and records it. Returning a row for the goal
|
|
51
|
+
// insert matters: createVersion stamps the criteria with `goal.id`, so a stub
|
|
52
|
+
// that answered {rows: []} would silently reproduce the goal-less-criteria bug
|
|
53
|
+
// it is here to catch.
|
|
54
|
+
function fakeDb({ goalRow = { id: 77, title: 'Foundations' } } = {}) {
|
|
55
|
+
const log = [];
|
|
56
|
+
const client = {
|
|
57
|
+
query: async (sql, params) => {
|
|
58
|
+
log.push({ sql, params });
|
|
59
|
+
if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
|
|
60
|
+
if (/INSERT INTO versions/i.test(sql)) return { rows: [{ id: params[0], name: params[1], status: params[2], track: params[3] }] };
|
|
61
|
+
if (/INSERT INTO goals/i.test(sql)) return { rows: [goalRow] };
|
|
62
|
+
if (/INSERT INTO done_when_criteria/i.test(sql)) {
|
|
63
|
+
return { rows: [{ id: log.length, version_id: params[0], criterion_id: params[1], criterion_md: params[2], sort_order: params[3], goal_id: params[4], satisfied: false }] };
|
|
64
|
+
}
|
|
65
|
+
return { rows: [] };
|
|
66
|
+
},
|
|
67
|
+
release() {},
|
|
68
|
+
};
|
|
69
|
+
return { log, pool: { connect: async () => client } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// deps.pool is the module's own test seam and is honoured ONLY under NODE_ENV=test,
|
|
73
|
+
// so this line is load-bearing: without it createVersion reaches for the real pool.
|
|
74
|
+
process.env.NODE_ENV = 'test';
|
|
75
|
+
const db = fakeDb();
|
|
76
|
+
const { createVersion } = require('../modules/lifecycle/db-versions.js');
|
|
77
|
+
|
|
78
|
+
const CRITERIA = [
|
|
79
|
+
{ criterion_id: 'first-loop', criterion_md: 'A task reaches shipped with credits.' },
|
|
80
|
+
{ criterion_id: 'second', criterion_md: 'And a second one does too.' },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
console.log('createVersion — the version, its first goal, and its criteria:');
|
|
84
|
+
|
|
85
|
+
const out = await createVersion({
|
|
86
|
+
id: 'V1', name: 'Foundations', status: 'building', track: 'product',
|
|
87
|
+
doneWhen: 'The loop closes.', criteria: CRITERIA, createdBy: 7,
|
|
88
|
+
}, { pool: db.pool });
|
|
89
|
+
|
|
90
|
+
await t('the version, a goal, and every criterion land in ONE transaction', async () => {
|
|
91
|
+
const kinds = db.log.map((c) => c.sql.trim().split(/\s+/).slice(0, 3).join(' ').toUpperCase());
|
|
92
|
+
assert.equal(kinds[0], 'BEGIN', 'opens a transaction');
|
|
93
|
+
assert.equal(kinds[kinds.length - 1], 'COMMIT', 'and commits it');
|
|
94
|
+
assert.ok(!db.log.some((c) => /ROLLBACK/i.test(c.sql)), 'nothing rolled back');
|
|
95
|
+
// A goal committed on its own connection would survive a rolled-back version
|
|
96
|
+
// create as a goal pointing at a version that does not exist.
|
|
97
|
+
assert.ok(db.log.some((c) => /INSERT INTO goals/i.test(c.sql)), 'the goal is created here, not by a second round-trip');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await t('the goal is created AFTER the version and BEFORE the criteria', async () => {
|
|
101
|
+
const at = (re) => db.log.findIndex((c) => re.test(c.sql));
|
|
102
|
+
const v = at(/INSERT INTO versions/i);
|
|
103
|
+
const g = at(/INSERT INTO goals/i);
|
|
104
|
+
const c = at(/INSERT INTO done_when_criteria/i);
|
|
105
|
+
assert.ok(v >= 0 && g >= 0 && c >= 0, 'all three statements ran');
|
|
106
|
+
assert.ok(v < g, 'the goal references the version, so the version exists first');
|
|
107
|
+
assert.ok(g < c, 'the criteria reference the goal, so the goal exists first');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await t('EVERY criterion carries the goal_id — the defect that made criteria uncloseable', async () => {
|
|
111
|
+
const crits = db.log.filter((c) => /INSERT INTO done_when_criteria/i.test(c.sql));
|
|
112
|
+
assert.equal(crits.length, CRITERIA.length, 'one insert per criterion');
|
|
113
|
+
for (const c of crits) {
|
|
114
|
+
// Assert the STATEMENT, not only the params. goal_id can sit in the insert
|
|
115
|
+
// list while VALUES still binds a literal NULL — which is this defect exactly,
|
|
116
|
+
// and a fixture that rebuilds the row out of params cannot see it happen.
|
|
117
|
+
assert.match(c.sql, /INSERT INTO done_when_criteria \(version_id, criterion_id, criterion_md, sort_order, goal_id\)/,
|
|
118
|
+
'goal_id is in the insert list');
|
|
119
|
+
assert.match(c.sql, /VALUES \(\$1, \$2, \$3, \$4, \$5\)/, 'and VALUES BINDS it, rather than a literal NULL');
|
|
120
|
+
assert.match(c.sql, /RETURNING[\s\S]*goal_id/, 'and the row comes back carrying it');
|
|
121
|
+
assert.equal(c.params[4], 77, "and it is THIS version's first goal");
|
|
122
|
+
}
|
|
123
|
+
for (const c of out.criteria) assert.equal(c.goal_id, 77, 'the returned rows say so too');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await t('the goal is returned, because the caller cannot file a task without its id', async () => {
|
|
127
|
+
assert.ok(out.goal, 'createVersion returns it');
|
|
128
|
+
assert.equal(out.goal.id, 77);
|
|
129
|
+
assert.equal(out.version.id, 'V1');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await t('the goal is titled for the version, and owned by its creator', async () => {
|
|
133
|
+
const g = db.log.find((c) => /INSERT INTO goals/i.test(c.sql));
|
|
134
|
+
assert.equal(g.params[0], 'V1', 'scoped to the version');
|
|
135
|
+
assert.equal(g.params[1], 'Foundations', 'named after the version by default');
|
|
136
|
+
assert.equal(g.params[5], 7, 'created_by is the caller — createGoal also makes them a lead');
|
|
137
|
+
assert.equal(g.params[2], 'The loop closes.', 'described by the version\'s done_when');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t('an explicit firstGoal overrides the default title and description', async () => {
|
|
141
|
+
const d2 = fakeDb();
|
|
142
|
+
await createVersion({ id: 'V2', name: 'Ignored', doneWhen: 'also ignored', criteria: [],
|
|
143
|
+
firstGoal: { title: 'Project creation', description: 'The picker and the wizard.' } }, { pool: d2.pool });
|
|
144
|
+
const g = d2.log.find((c) => /INSERT INTO goals/i.test(c.sql));
|
|
145
|
+
assert.equal(g.params[1], 'Project creation');
|
|
146
|
+
assert.equal(g.params[2], 'The picker and the wizard.');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t('a blank firstGoal title falls back to the version rather than creating an unnamed goal', async () => {
|
|
150
|
+
const d3 = fakeDb();
|
|
151
|
+
await createVersion({ id: 'V3', name: 'Third', criteria: [], firstGoal: { title: ' ' } }, { pool: d3.pool });
|
|
152
|
+
const g = d3.log.find((c) => /INSERT INTO goals/i.test(c.sql));
|
|
153
|
+
assert.equal(g.params[1], 'Third', 'goals.title is NOT NULL — a blank title must never reach it');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await t('a version with NO criteria still gets its goal (the goal is the point, not the criteria)', async () => {
|
|
157
|
+
const d4 = fakeDb();
|
|
158
|
+
const r = await createVersion({ id: 'V4', name: 'Fourth', criteria: [] }, { pool: d4.pool });
|
|
159
|
+
assert.ok(d4.log.some((c) => /INSERT INTO goals/i.test(c.sql)), 'still created');
|
|
160
|
+
assert.equal(r.goal.id, 77);
|
|
161
|
+
assert.equal(r.criteria.length, 0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
console.log(`\nversion_first_goal: ${passed} passed, ${failed} failed`);
|
|
165
|
+
process.exit(failed ? 1 : 0);
|
package/tests/versions_route.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const VERSIONS_POST_SCHEMA = {
|
|
|
32
32
|
done_when: { type: 'string', maxLength: 8000 },
|
|
33
33
|
scope_doc_path: { type: 'string', maxLength: 256 },
|
|
34
34
|
criteria: { type: 'array', maxItems: 100 },
|
|
35
|
+
first_goal: { type: 'object' },
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
// ---- the router builds (smoke: imports auth/db/branding/_helpers cleanly) ----
|
|
@@ -46,6 +47,17 @@ test('INIT: minimal create (id + name only) passes', () => {
|
|
|
46
47
|
assert.equal(validate({ id: 'V1', name: 'Prove the loop' }, VERSIONS_POST_SCHEMA), null);
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
// The version's first goal (task 1003701). init does NOT send one — it takes the
|
|
51
|
+
// default, which names the goal after the version — so the minimal create above
|
|
52
|
+
// staying valid is half the contract, and this is the other half.
|
|
53
|
+
test('INIT: an explicit first_goal object passes', () => {
|
|
54
|
+
assert.equal(validate({ id: 'V1', name: 'Prove the loop', first_goal: { title: 'Foundations' } }, VERSIONS_POST_SCHEMA), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('BAD: first_goal as a string is refused (it is an object or nothing)', () => {
|
|
58
|
+
assert.notEqual(validate({ id: 'V1', name: 'n', first_goal: 'Foundations' }, VERSIONS_POST_SCHEMA), null);
|
|
59
|
+
});
|
|
60
|
+
|
|
49
61
|
test('INIT: full create with criteria array passes', () => {
|
|
50
62
|
const body = {
|
|
51
63
|
id: 'V1',
|