@bongos/core 1.19.681 → 1.19.683
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 +33 -23
- package/.gitattributes +28 -0
- package/docs/adr/0280-the-unattended-lane-may-drive-a-co-tenant-because-the-roster-is-the-decision.md +63 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/conflict-resolve.js +13 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-api-client.js +7 -2
- package/scripts/gds/git-merge-regen.js +64 -0
- package/scripts/gds/init.js +1 -1
- package/scripts/gds/upgrade.js +14 -4
- package/scripts/gds/worktree.js +20 -0
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +13 -0
- package/tests/git_merge_regen.mjs +60 -0
- package/tests/update_subscription_engine.mjs +415 -0
- package/tests/upgrade.mjs +41 -1
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.683",
|
|
6
|
+
"core_contract": "1.19.683",
|
|
7
|
+
"source_commit": "40eeb484085efb69d5aaa3645233d23854e2bbc1",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-12T17:02:13.044Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 477,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2125,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2626,
|
|
20
|
+
"tree_sha256": "aa8a467c8dcd7322882229d5b60da130eb5b67e726e38a0dde2b34d2ce6bfa4a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -322,7 +322,7 @@
|
|
|
322
322
|
{
|
|
323
323
|
"path": ".gitattributes",
|
|
324
324
|
"mode": "0000644",
|
|
325
|
-
"sha256": "
|
|
325
|
+
"sha256": "38bf33dd14a7bff6545f961c4f30172ac850f6a0578900a3c10295c4fc0f27e9"
|
|
326
326
|
},
|
|
327
327
|
{
|
|
328
328
|
"path": "CLAUDE.md",
|
|
@@ -1909,10 +1909,15 @@
|
|
|
1909
1909
|
"mode": "0000644",
|
|
1910
1910
|
"sha256": "46e65d06711ad356563b01b824c416d03cecf135f9f69a60e469710e91cb3f62"
|
|
1911
1911
|
},
|
|
1912
|
+
{
|
|
1913
|
+
"path": "docs/adr/0280-the-unattended-lane-may-drive-a-co-tenant-because-the-roster-is-the-decision.md",
|
|
1914
|
+
"mode": "0000644",
|
|
1915
|
+
"sha256": "630f6460b3484f4cc629cce9c431f824bb8363d30a249db9e21e89863995c7ac"
|
|
1916
|
+
},
|
|
1912
1917
|
{
|
|
1913
1918
|
"path": "docs/adr/README.md",
|
|
1914
1919
|
"mode": "0000644",
|
|
1915
|
-
"sha256": "
|
|
1920
|
+
"sha256": "49273e63c50c201cd70200a4f0db7f233e496827823adb9449638eb5a0c1d030"
|
|
1916
1921
|
},
|
|
1917
1922
|
{
|
|
1918
1923
|
"path": "docs/api-reference.md",
|
|
@@ -2797,7 +2802,7 @@
|
|
|
2797
2802
|
{
|
|
2798
2803
|
"path": "docs/module-api-changelog.md",
|
|
2799
2804
|
"mode": "0000644",
|
|
2800
|
-
"sha256": "
|
|
2805
|
+
"sha256": "abe1e67ec7bd2d5d4bf27a2b5d8b4337fc1d4b7374c1edab6c5392016a83fde3"
|
|
2801
2806
|
},
|
|
2802
2807
|
{
|
|
2803
2808
|
"path": "docs/modules-contract.md",
|
|
@@ -5642,7 +5647,7 @@
|
|
|
5642
5647
|
{
|
|
5643
5648
|
"path": "modules/lifecycle/conflict-resolve.js",
|
|
5644
5649
|
"mode": "0000644",
|
|
5645
|
-
"sha256": "
|
|
5650
|
+
"sha256": "3e20f4965d1d83d17f8e777201bd7e4fd7857843295180542914de16ae51e936"
|
|
5646
5651
|
},
|
|
5647
5652
|
{
|
|
5648
5653
|
"path": "modules/lifecycle/criterion-suggest.js",
|
|
@@ -7777,12 +7782,12 @@
|
|
|
7777
7782
|
{
|
|
7778
7783
|
"path": "package-lock.json",
|
|
7779
7784
|
"mode": "0000644",
|
|
7780
|
-
"sha256": "
|
|
7785
|
+
"sha256": "81888175ae0227d30c633c8f51a25936418cfdbf1d36c77bdd7c329d41d0c429"
|
|
7781
7786
|
},
|
|
7782
7787
|
{
|
|
7783
7788
|
"path": "package.json",
|
|
7784
7789
|
"mode": "0000644",
|
|
7785
|
-
"sha256": "
|
|
7790
|
+
"sha256": "b5cd5f5c6b24980f4b85986be1919f4cb2697c3ba219277d51b1ef07de72343b"
|
|
7786
7791
|
},
|
|
7787
7792
|
{
|
|
7788
7793
|
"path": "public-docs/index.html",
|
|
@@ -8282,7 +8287,7 @@
|
|
|
8282
8287
|
{
|
|
8283
8288
|
"path": "scripts/gds/gen-api-client.js",
|
|
8284
8289
|
"mode": "0000644",
|
|
8285
|
-
"sha256": "
|
|
8290
|
+
"sha256": "2d8b35709e19b339bc55c57237f2add36fa6bdeb83a9b036faa902b0c4b8e3f9"
|
|
8286
8291
|
},
|
|
8287
8292
|
{
|
|
8288
8293
|
"path": "scripts/gds/gen-api-docs.js",
|
|
@@ -8332,7 +8337,7 @@
|
|
|
8332
8337
|
{
|
|
8333
8338
|
"path": "scripts/gds/git-merge-regen.js",
|
|
8334
8339
|
"mode": "0000644",
|
|
8335
|
-
"sha256": "
|
|
8340
|
+
"sha256": "9d65420d85f20d10dba3af63c45d8dd0425ee11dd3d5cfa14d3019abf7354aed"
|
|
8336
8341
|
},
|
|
8337
8342
|
{
|
|
8338
8343
|
"path": "scripts/gds/go-live.js",
|
|
@@ -8372,7 +8377,7 @@
|
|
|
8372
8377
|
{
|
|
8373
8378
|
"path": "scripts/gds/init.js",
|
|
8374
8379
|
"mode": "0000644",
|
|
8375
|
-
"sha256": "
|
|
8380
|
+
"sha256": "b0971625852fca93ca474d2f9e31e3601397f1b9efbef3241a0b0c47f17d925f"
|
|
8376
8381
|
},
|
|
8377
8382
|
{
|
|
8378
8383
|
"path": "scripts/gds/install-git-hooks.js",
|
|
@@ -9112,7 +9117,7 @@
|
|
|
9112
9117
|
{
|
|
9113
9118
|
"path": "scripts/gds/upgrade.js",
|
|
9114
9119
|
"mode": "0000644",
|
|
9115
|
-
"sha256": "
|
|
9120
|
+
"sha256": "4f30ee3036846f449f6fc8143da32b3767376352743d373c39ace2f50f66e913"
|
|
9116
9121
|
},
|
|
9117
9122
|
{
|
|
9118
9123
|
"path": "scripts/gds/validate-design.js",
|
|
@@ -9142,7 +9147,7 @@
|
|
|
9142
9147
|
{
|
|
9143
9148
|
"path": "scripts/gds/worktree.js",
|
|
9144
9149
|
"mode": "0000644",
|
|
9145
|
-
"sha256": "
|
|
9150
|
+
"sha256": "849cd462b9c50866ca1582ed2156fb68ac76699c8270796d1daf6942c09713dc"
|
|
9146
9151
|
},
|
|
9147
9152
|
{
|
|
9148
9153
|
"path": "scripts/hall-preview/README.md",
|
|
@@ -9542,7 +9547,7 @@
|
|
|
9542
9547
|
{
|
|
9543
9548
|
"path": "src/module-api.js",
|
|
9544
9549
|
"mode": "0000644",
|
|
9545
|
-
"sha256": "
|
|
9550
|
+
"sha256": "2adaf10cbc0a2e65aeaa820f95b2b789c8e66c398c5f56299e9d1f52e82b86a1"
|
|
9546
9551
|
},
|
|
9547
9552
|
{
|
|
9548
9553
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10212,7 +10217,7 @@
|
|
|
10212
10217
|
{
|
|
10213
10218
|
"path": "tests/conflict_resolve.mjs",
|
|
10214
10219
|
"mode": "0000644",
|
|
10215
|
-
"sha256": "
|
|
10220
|
+
"sha256": "da8182c4235c7735bdee351cfa7dc1146f7ded33b9d594dc0ba498a3b5206d83"
|
|
10216
10221
|
},
|
|
10217
10222
|
{
|
|
10218
10223
|
"path": "tests/connections_api.mjs",
|
|
@@ -10642,7 +10647,7 @@
|
|
|
10642
10647
|
{
|
|
10643
10648
|
"path": "tests/git_merge_regen.mjs",
|
|
10644
10649
|
"mode": "0000644",
|
|
10645
|
-
"sha256": "
|
|
10650
|
+
"sha256": "a55ae255245a53429cc7948e35ea0515bf58307f826d605d6ffd52cc60196ef8"
|
|
10646
10651
|
},
|
|
10647
10652
|
{
|
|
10648
10653
|
"path": "tests/github_push_ancestry.mjs",
|
|
@@ -12984,10 +12989,15 @@
|
|
|
12984
12989
|
"mode": "0000644",
|
|
12985
12990
|
"sha256": "9debf9ecdb02b986c882f3d29bdf4b5fe2154cb4e7639db0fd0a0336cd4bdc3b"
|
|
12986
12991
|
},
|
|
12992
|
+
{
|
|
12993
|
+
"path": "tests/update_subscription_engine.mjs",
|
|
12994
|
+
"mode": "0000644",
|
|
12995
|
+
"sha256": "a2ba2dca70c6192313c7a64e6dcbb5d7f9dbf2dc457592e643f1d59154b2665f"
|
|
12996
|
+
},
|
|
12987
12997
|
{
|
|
12988
12998
|
"path": "tests/upgrade.mjs",
|
|
12989
12999
|
"mode": "0000644",
|
|
12990
|
-
"sha256": "
|
|
13000
|
+
"sha256": "492b9dc7efdd6f63b81f1662d5fdc9a7541b7f8ccc6c0ec77a456ad3f574ee74"
|
|
12991
13001
|
},
|
|
12992
13002
|
{
|
|
12993
13003
|
"path": "tests/upgrade_persist_pin.mjs",
|
package/.gitattributes
CHANGED
|
@@ -112,6 +112,34 @@ src/bongos/routes/CLAUDE.md merge=otb-regen
|
|
|
112
112
|
docs/copy-registry.json merge=otb-regen
|
|
113
113
|
docs/copy-inventory.md merge=otb-regen text eol=lf
|
|
114
114
|
|
|
115
|
+
# Generated API contract + client (task 1003537). gen-api-docs.js writes the first
|
|
116
|
+
# three of these wholesale from a scan of the route files; gen-api-client.js writes the
|
|
117
|
+
# six client files wholesale from that spec. Nothing is hand-authored in any of the
|
|
118
|
+
# nine, so they are the same whole-file shape as docs/session-log-index.md above — and
|
|
119
|
+
# any branch that adds or changes a route regenerates all nine differently and conflicts
|
|
120
|
+
# on ordinary text. Measured on task 1002334: main took 41 commits in 3 hours and four
|
|
121
|
+
# consecutive merge -> regenerate -> reship cycles each lost the race to a fresh conflict
|
|
122
|
+
# in exactly these files, with a PASSING grade the whole time. REGENERABLE in
|
|
123
|
+
# git-merge-regen.js carries the matching rules, and tests/git_merge_regen.mjs pins the
|
|
124
|
+
# two lists as one set.
|
|
125
|
+
#
|
|
126
|
+
# EVERY PATH IS LISTED EXPLICITLY -- deliberately no `clients/bongos-client/*` glob, for
|
|
127
|
+
# two independent reasons. The lockstep test compares these tokens against REGENERABLE as
|
|
128
|
+
# literal path strings, and the driver is handed git's %P, so a glob would match nothing
|
|
129
|
+
# on either side. And clients/bongos-client/examples/hello-world.mjs is HAND-WRITTEN (the
|
|
130
|
+
# generator owns only the six files below): a glob that captured it would route it through
|
|
131
|
+
# a driver that has no rule for it, forcing a conflict git would otherwise merge cleanly --
|
|
132
|
+
# the same trap the leading slash on /CLAUDE.md above exists to avoid.
|
|
133
|
+
docs/api/openapi.json merge=otb-regen
|
|
134
|
+
docs/api-reference.md merge=otb-regen
|
|
135
|
+
docs/routes-permissions.md merge=otb-regen
|
|
136
|
+
clients/bongos-client/index.mjs merge=otb-regen
|
|
137
|
+
clients/bongos-client/index.cjs merge=otb-regen
|
|
138
|
+
clients/bongos-client/bongos-client.global.js merge=otb-regen
|
|
139
|
+
clients/bongos-client/index.d.ts merge=otb-regen
|
|
140
|
+
clients/bongos-client/package.json merge=otb-regen
|
|
141
|
+
clients/bongos-client/README.md merge=otb-regen
|
|
142
|
+
|
|
115
143
|
# Append-only index tables (task 1385 / ADR 0082). docs/adr/README.md is a
|
|
116
144
|
# hand-maintained ADR index whose ONLY recurring conflict is two branches each
|
|
117
145
|
# appending a new row — there is no generator to regen it from, so `otb-regen`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# ADR 0280 — The unattended lane may drive a co-tenant, because the roster entry *is* the per-instance decision
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-12
|
|
5
|
+
- **Task:** [1003843](https://cloudbongos.com/builders#/task/1003843) (goal 1000106) — from [task 1003521](https://cloudbongos.com/builders#/task/1003521), which built co-tenant mode into `go-live.js` and deliberately left this caller alone.
|
|
6
|
+
- **Adjacent:** [ADR 0279](<redacted>.md) (why this lane prefers `go-live.js` at all) · [ADR 0136](<redacted>.md) (the subscription policy core).
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
`go-live.js` grew **co-tenant mode** in task 1003521: `deployTimer` became optional (a provisioned instance under `/srv/<host>/<slug>` has no pull-deploy timer to stop), and `pinMode: local-commit | leave-dirty` stopped it pushing the pin into the **customer's own** GitHub repo.
|
|
11
|
+
|
|
12
|
+
The nightly auto-subscription lane (`.claude/scheduled-tasks/core-update-subscription/subscribe.js`) could not reach any of it, because it keeps a **second copy of go-live's target contract**:
|
|
13
|
+
|
|
14
|
+
1. `GO_LIVE_REQUIRED` still listed `deployTimer`, so `goLiveTargetFor()` returned `{ok: false, missing: ['deployTimer']}` for every co-tenant.
|
|
15
|
+
2. `rawTopologyFor()` reads a fixed field list off `config/update-subscriptions.json`, and `pinMode` was not in it — the same stale-normalizer trap that function's own comment was written to fix, one field later.
|
|
16
|
+
|
|
17
|
+
Task 1003521 deferred the fix on the grounds that widening it "would have meant changing the unattended lane's policy about which instances it may deploy, which is a judgement call." **That premise does not survive reading the fallback.** `mapped.ok === false` does not mean "skip this instance" — it means "deploy it the *weaker* way": a bare `bongos upgrade` with no backup and no deploy-timer guard, and, because the roster's `--commit-pin` support is independent of all this, a pin **committed and pushed** — into the co-tenant's own repo, which is precisely what `pinMode` exists to prevent.
|
|
18
|
+
|
|
19
|
+
So the lane was already auto-deploying co-tenants. The only question ever open was whether it did so well.
|
|
20
|
+
|
|
21
|
+
A third defect sat behind the other two, and would have turned the reported silent downgrade into a hard failure if only the first had been fixed: the normalizer yields `deployTimer: null` for an absent field, an explicit `null` **survives `JSON.stringify`** into `--target-json`, and go-live's `validateTarget` runs `isSafeToken(null)` on it and rejects the whole target. Dropping `deployTimer` from `GO_LIVE_REQUIRED` alone would have handed go-live a target it refuses.
|
|
22
|
+
|
|
23
|
+
**The remaining defects sat outside the go-live call entirely, and they are what made "reachable" different from "safe".** The lane writes the instance's pin in **three** places, and every one of them decided whether to push without asking what mode it was in:
|
|
24
|
+
|
|
25
|
+
1. **The durability net after the upgrade** — `commitPin()` commits the pin files and **pushes to `origin`**. Under `local-commit` go-live leaves the tree clean, so the net no-ops and is correct by luck. Under **`leave-dirty`** the pin is in the working tree *by design*, so the net committed it and pushed it into the customer's repo.
|
|
26
|
+
2. **The HEAL-FIRST step before the upgrade**, which is the worst of the three because it fires on the **happy path**. A `leave-dirty` co-tenant's steady state after a *successful* upgrade IS a dirty pin — so the next scheduled sweep read it as "a leftover from an earlier sweep", committed it and pushed it. Reliably, every sweep, not just after a crash. (Found by the grader; it shipped past a green suite on the first round because the decision was an inline `if` inside a 250-line loop that no test could reach.)
|
|
27
|
+
3. **The direct-upgrade fallback**, which passes `--commit-pin` to `upgrade.js` — and that flag commits *and pushes*. So a co-tenant on an older core had its pin pushed anyway, by the very path taken when go-live's co-tenant support was missing.
|
|
28
|
+
|
|
29
|
+
Reaching co-tenant mode without closing all three would have been worse than not reaching it at all: the lane would honour `pinMode` at the one site that names it and undo it at the two that do not.
|
|
30
|
+
|
|
31
|
+
## Decision
|
|
32
|
+
|
|
33
|
+
**The nightly lane may drive a co-tenant, and needs no new gate to do it — because enrolling an instance in `config/update-subscriptions.json` already IS the explicit, per-instance operator decision.** The roster ships empty, an instance is touched only after an operator lists it, and the whole routine is behind the autonomy flag (`requiresAutonomy: true`, default OFF). Adding a second consent gate in front of that would be asking the same person the same question twice.
|
|
34
|
+
|
|
35
|
+
The change therefore makes the *existing* behaviour correct rather than widening it. Concretely, in `subscribe.js`:
|
|
36
|
+
|
|
37
|
+
- `GO_LIVE_REQUIRED` now **mirrors** go-live's `REQUIRED_FIELDS` exactly, and a test reads **both source files** and fails on drift. The contract is someone else's; the copy is the liability.
|
|
38
|
+
- `deployTimer` moves into the optional-field loop, which drops falsy values, so a co-tenant **omits the key** rather than carrying a `null` that go-live would reject.
|
|
39
|
+
- `rawTopologyFor` carries `pinMode`, and `goLiveTargetFor` passes it through.
|
|
40
|
+
- The capability probe in `resolveGoLive` gains a **`needsCoTenant`** leg, checked **only when the target actually needs it** — a target with no `deployTimer`, or one naming `pinMode`. Both are rejected outright by a go-live.js predating 1003521 (the missing required field; the unknown-field check), so the fail-closed rule of task 1003212 applies: probe the capability, fall back if absent.
|
|
41
|
+
- `goLiveEngineFor()` makes that decision **once**, for both the dry run and the apply path. They previously re-derived it separately, and the co-tenant probe gave them a third thing to keep in step — a preview that promises an engine the real sweep will not use is its own quiet failure.
|
|
42
|
+
- **All three pin-writing sites obey one policy, read from the roster.** `pinModeOf()` reads the **roster entry** — the operator's declared intent — not the go-live target, because deriving it from the target would silently revert a co-tenant to `push` on exactly the runs that fall back to a direct upgrade. `commitPin()` takes `push` (default `true`, so the platform instance is unchanged); the fallback withholds `--commit-pin` unless the mode is `push`; and the "pin is not durable" advice no longer tells an operator to `git push` a repo we do not own.
|
|
43
|
+
- **The heal decision is a named function, `healDecision(pre, pinMode)`.** Under `leave-dirty` a dirty pin is `by-design`, not a leftover — go-live's dirty-checkout halt reads the **core** checkout, not the instance, so leaving it dirty wedges nothing. Extracting it is the point as much as the fix: all three bugs were a site deciding without asking, and an inline `if` in a long loop is exactly what no test can reach. Non-pin changes still `halt` in **every** mode — a human's uncommitted work stops the lane regardless of pin policy, and the test pins that ordering.
|
|
44
|
+
- **An unknown `pinMode` is refused, not defaulted.** Every unrecognised value would otherwise fall through to the push branch, so a typo in the roster degrades to the *most* dangerous behaviour. The instance is skipped with the expected values named. `PIN_MODES` mirrors go-live's enum, pinned by the same read-both-sources test as `GO_LIVE_REQUIRED`.
|
|
45
|
+
|
|
46
|
+
**Why the probe is conditional and not unconditional.** Probing for `pinMode` on every target would strip the go-live path from any instance sitting on a core between the `--target-json` release and 1003521's — a live regression traded for a hypothetical one. The capability is demanded only where it is used.
|
|
47
|
+
|
|
48
|
+
## Consequences
|
|
49
|
+
|
|
50
|
+
- A provisioned co-tenant listed in the roster now takes the **stronger** deploy path: pg_dump backup, the served-version read-back of ADR 0279, and a pin that is committed locally instead of pushed into a repo we do not own — end to end, including the lane's own net after go-live returns.
|
|
51
|
+
- A co-tenant on an older core **falls back and says so**, naming co-tenant support specifically, so an operator can tell it apart from a malformed roster entry.
|
|
52
|
+
- Ordinary instances are unchanged — including the mid-generation ones the conditional probe protects.
|
|
53
|
+
- `GO_LIVE_REQUIRED` and `PIN_MODES` drifting from go-live's contract are now **test failures**, not silent downgrades. This is the second time this copy has drifted; the next time it moves, CI says so.
|
|
54
|
+
- A source-level test asserts that **every** `commitPin` call site names `push`, and that the fallback gates `--commit-pin`. The recurring bug was never one site — it was the absence of anything that could see all of them at once.
|
|
55
|
+
|
|
56
|
+
## Rejected
|
|
57
|
+
|
|
58
|
+
- **A second consent gate** (an opt-in `allowCoTenant` roster flag, or an operator-only command): the roster entry already carries that consent, and the routine is autonomy-gated on top of it.
|
|
59
|
+
- **Deferring to the fleet control plane** ([task 1001948](https://cloudbongos.com/builders#/task/1001948)): that epic is unspecced, and leaving this until then does not leave co-tenants alone — it leaves them on the weaker path, pushing pins into customer repos.
|
|
60
|
+
- **Dropping `deployTimer` from `GO_LIVE_REQUIRED` and nothing else** (the fix as originally proposed): it trades a silent downgrade for a hard target rejection, because of the surviving `null`.
|
|
61
|
+
- **Probing for co-tenant support unconditionally:** regresses every instance on a mid-generation core.
|
|
62
|
+
- **Reaching co-tenant mode without teaching the durability net about it:** `leave-dirty` would have been committed and pushed to the customer's remote by the net running seconds after go-live deliberately did not — nominal support that breaks its own promise.
|
|
63
|
+
- **Teaching `loadSubscriptions()` about `pinMode`** instead of `rawTopologyFor`: that normalizer ships in the *control plane's* vendored core, which nobody bumps — trusting it is the exact trap `rawTopologyFor` was written to route around.
|
package/docs/adr/README.md
CHANGED
|
@@ -371,3 +371,4 @@ This keeps the decision history honest and traceable.
|
|
|
371
371
|
| 0277 | [**A box is "in use" only while a human is attached, and the claim expires** ([task 1003507](https://cloudbongos.com/builders#/task/1003507) · goal 1000095 — *Working area 6*, criterion `wa6-role-experience`). `sweep-idle` skipped any box with `claude_active = true` at ANY age, and `claude_active` is a LATCH, not a level: only a heartbeat ping writes it, and `infra/box-heartbeat.sh` exits WITHOUT pinging when it sees nothing — so silence, the very signal the sweep exists to act on, could never clear it. Reproduced against the shipped selector: skipped at `idleMinutes` of 10, 120, 1440 and **5,256,000** (ten years). Not reaped late; never. The second leg is that `load > 0.2` kept `last_activity_at` bumping every 5 minutes anyway (a devcontainer plus an idle `claude` clears that floor on its own), so EITHER leg alone kept a box alive — which is why bounding only the veto looks like a fix and is not: the incident box's heartbeat was FRESH. Measured: `example-owner`'s box up since 2026-08-12, tmux `otb` UNATTACHED since 2026-08-15 18:01 UTC, `claude` burning 16 min of CPU across 25h of wall clock, **$20.21** of mostly-unattended compute. A THIRD defect, found while testing this and confirmed on clean `origin/main`, made the sweep inert regardless: `loadDeps()` read `_deps` before anything declared it, so it threw `ReferenceError` on first call and every command resolving a DigitalOcean client went with it — including `sweep-idle --apply`, which builds that client before the park loop. **The idle sweep could not park any box at all**, hidden because the suite's only `apply: true` test relied on the veto emptying the idle set before `makeDo()` was reached: one bug shielded by the other. **Decision: a box may not stay active longer than `BOX_UNATTENDED_MAX_HOURS` (12) without evidence a HUMAN was attached.** The heartbeat already computed that signal and folded it into one boolean; it now reports `attached` separately (login session, inbound SSH, open ttyd, or an ATTACHED tmux client via `#{session_attached}` — the signal that separates this box from a working one) and the server stamps `last_attached_at`. `claude_active` keeps its veto but it EXPIRES, bounded by a `claude_active_since` edge stamp. `pgrep -x claude` and the load floor remain reasons the box PINGS, never evidence anyone is THERE — a running process is not a person, and that distinction is the whole decision. The two `NULL` defaults deliberately DISAGREE: `last_attached_at` NULL means "no data" and keeps a pre-1003507 box on the old behaviour (core_238 pointedly does NOT backfill it — a backfilled `now()` starts a clock nothing can advance and parks every un-upgraded box one cap later, and box source sync is not prompt: idea 1000745 records 257 commits behind for three days), while `claude_active_since` NULL is REFUSED because an unknown latch age is the forever-latch itself, and is backfilled so the state is unreachable after deploy. 12h because parking is reversible since task 1002726 (snapshot kept), so a false positive costs one wake against $20.21 for no cap. Both clocks clear at park/wake/deprovision, or a woken box inherits an expired latch and is parked instantly — the fix reintroducing the bug from the far side. Rejected: tracking attachment INSTEAD of bounding the latch (the silent box keeps `true` forever, so the reported hole survives); bounding the latch alone (built first, and the verification probe caught it — the fresh heartbeat meant lifting the veto changed nothing); deleting the load floor (it is what makes an autonomous run count); measuring from `active_since` (that is uptime — parks a box worked on for days); raising `BOX_IDLE_MINUTES` (no threshold reaches an unbounded veto).](<redacted>.md) | dev box / cost / idle sweep |
|
|
372
372
|
| 0278 | [**A gated project still takes applications, and the exemption is scoped to the verb** ([task 1003525](https://cloudbongos.com/builders#/task/1003525) · the apply write itself in [task 1003624](https://cloudbongos.com/builders#/task/1003624) · goal 1000106 — *Working area 1, Project creation*; owner decision 2026-09-11). A project's owner sets who may SEE it (`platformVisibility`, [ADR 0192](<redacted>.md)) and who may JOIN it (`joinability`, [ADR 0194](<redacted>.md)) independently — and set to their middle values, members-only AND apply-to-join, the project took no applications at all: the member door refused every cookie-less request with `401` before the public `POST <api>/access-requests` could answer, because that write was not on the exempt list. The two settings composed into **"nobody can apply"**, which nobody chose. It survived because nothing LIED about it — the hub's join box relayed the project's own `401` honestly as `members_only`, and the hall's landing, where the apply form lives, is itself behind the door; the composition was simply unreachable. Found by the R14 proof ([task 1002333](https://cloudbongos.com/builders#/task/1002333)). ADR 0192 §3 had fixed the exempt list at "the door, the manifest, the probes and the downloads — and nothing wider" and left widening it as an owner call, which is what this is. **Decision: yes — and BOTH halves are exempted, each scoped to one path and one verb.** `POST <api>/access-requests` (it grants nothing — an application is a row in a queue the owner still reviews, [ADR 0201](<redacted>.md), already public on every non-gated project) and `GET <api>/access-requests/status` (without it the answer is half an answer: `bongos login` cannot re-poll the device flow after a `not_approved` — the `device_code` is spent — so an applicant would file a request and then wait on an approval they can never observe). `EXEMPT` entries may now be `{ re, methods }` beside the bare `RegExp`s, and `isExempt(path, method)` takes the verb as an OPTIONAL second argument that **fails closed** for a scoped entry when none is given, so the one-argument static callers cannot accidentally widen. **The verb is load-bearing, not tidiness:** the bare `GET` on `<api>/access-requests` is the OWNER'S QUEUE (`requireBuilder` + `access_request.review`), the surface listing would-be builders by name with their vouch state — a path-only exemption would have silently taken the member door off the front of it, leaving one layer where there were two, and the queue's own `requirePermission` still holding is exactly what makes that loss easy to miss. **It opens no oracle the gate was closing:** the status route's boolean twin `GET <api>/auth/web/admission-status` is ALREADY reachable on a gated project inside the `auth/*` subtree §3 exempts whole (§3 records that cost in as many words), and the two share ONE per-IP budget on purpose ([ADR 0209](<redacted>.md)) so neither can be alternated against the other. What it DOES add, stated as the honest cost: applicant detail — `pending`/`dismissed`/`none` over the twin's bare `admitted`. Whether that answer should collapse is ADR 0209's still-open owner question and is deliberately NOT decided here. No hub change: `joinRelayOutcome` maps the RELAYED status, so it carries the project's real answer the moment the `401` stops. Rejected: "gated means gated" — hide *Apply to join* and say so in the manage blurb (coherent, and the call went the other way); exempting the path without the verb; exempting the write alone; collapsing the status response while the route happened to be open (that is how a deferred decision gets made by accident).](<redacted>.md) | project visibility / join door / member door |
|
|
373
373
|
| 0279 | [**An upgrade is proven by the served version, not by a health check** ([task 1002884](https://cloudbongos.com/builders#/task/1002884) · goal 1000090 — *Working area 4, Bongos Core distribution*; from idea 1000682). On 2026-08-11 the auto-upgrade sweep printed `✓ upgrade complete — core 1.19.13 → 1.19.56`, wrote a success row to `core_upgrades` and exited 0 while live kept serving **1.19.13**. Three shipped checks formed a closed loop that could not see the failure they existed to catch: the `systemctl restart` failed with `Interactive authentication required` (a `User=` unit, no TTY) and `upgrade.js` treated it as a WARNING and fell through; `readInstalledCoreVersion()` then confirmed the version on **disk**, where `npm install` had correctly put it; and `pollHealth()` got a 200 from the **still-running old process**, because a health check confirms a port is served, never *what* serves it. A lying tool is worse than a broken one — nothing goes looking. The damage outlived the incident: `subscribe.js` had already routed the unattended lane around `bongos upgrade` in favour of `go-live.js`, citing this false-pass in a comment. **Decision: a bump is confirmed by asking the running process what version it is.** (1) A failed restart enters the same auto-rollback path as a failed install/migrate/health and exits non-zero — rolling back rather than merely erroring keeps disk and process consistent, since disk-ahead-of-process is the state that made the incident invisible. (2) The served version is read back from `/version` (`coreVersion`, since 1.17.2, prelaunch-gate exempt), defaulting to the `--health-url` origin and overridable via `--version-url` — derived rather than opt-in because every existing call site passes only `--health-url`, and a check you must opt into is off exactly where it is needed. (3) A **mismatch** fails (proof of failure → roll back); an **unreadable** endpoint only warns (absence of proof — refusing every such bump would regress harder than the false-pass), except under an explicit `--version-url`, which asks for proof and therefore gets a failure. That strict mode is what the unattended subscription lane now passes, its roster already carrying the URL. **Escalation is `sudo -n`, not a hand-placed polkit rule:** `restartService()` retries a failed restart through `sudo -n` when not root — the idiom `dev.js`/`dev-lib.js` already use and consistent with `provision.js`, `upgrade.js` having been the one place that restarted without escalating. The live `/etc/polkit-1/rules.d/<redacted>.rules` mitigation is superseded: a rebuilt box inherits code, not hand-placed `/etc` files (polkit route kept in [`docs/recipes/instance-service-restart.md`](../recipes/instance-service-restart.md)). `--no-health-check` stays the single escape hatch and now waives the read-back too. Rejected: comparing `startedAt` (cannot distinguish a restart onto the same old core from one onto the new); making an unreadable endpoint fatal by default; requiring `--version-url` everywhere (absent from every current call site — the same "off where it matters" failure in a new costume); keeping polkit as the answer.](<redacted>.md) | core distribution / upgrade verification |
|
|
374
|
+
| 0280 | [**The unattended lane may drive a co-tenant, because the roster entry IS the per-instance decision** ([task 1003843](https://cloudbongos.com/builders#/task/1003843) · goal 1000106; from [task 1003521](https://cloudbongos.com/builders#/task/1003521), which built co-tenant mode into `go-live.js` and deliberately left this caller alone). `subscribe.js` keeps a SECOND COPY of go-live's target contract, and it drifted: `GO_LIVE_REQUIRED` still listed `deployTimer` after 1003521 made it optional, so `goLiveTargetFor()` returned `missing: ['deployTimer']` for every provisioned co-tenant, and `rawTopologyFor()`'s fixed field list dropped `pinMode` before the target was built — the same stale-normalizer trap that function's own comment was written to fix, one field later. **1003521 deferred this as a policy judgement call; reading the fallback shows the premise was wrong.** `mapped.ok === false` never meant "skip this instance" — it meant "deploy it the WEAKER way": a bare `bongos upgrade`, no backup, no deploy-timer guard, and a pin COMMITTED AND PUSHED into the co-tenant's own repo, which is exactly what `pinMode` exists to prevent. The lane was already auto-deploying co-tenants; the only open question was whether it did so well. A THIRD defect sat behind the other two and would have turned the silent downgrade into a hard failure had only the first been fixed: the normalizer yields `deployTimer: null`, an explicit null SURVIVES `JSON.stringify` into `--target-json`, and go-live's `validateTarget` runs `isSafeToken(null)` on it and rejects the whole target. A FOURTH sat PAST the go-live call and is what separates "reachable" from "safe": after any successful upgrade the lane runs its own pin net, `commitPin()`, which commits the pin files and PUSHES them to `origin` — a net that predates `pinMode`. Under `local-commit` go-live leaves the tree clean so it no-ops by luck; under **`leave-dirty`** the pin is in the working tree BY DESIGN, so the net committed it and pushed it into the customer's own repo — the lane honouring `pinMode` right up to the moment it undid it. **Decision: the lane may drive a co-tenant and needs no new gate, because listing an instance in `config/update-subscriptions.json` already IS the explicit per-instance operator decision** — the roster ships empty, nothing is touched until an operator lists it, and the routine is autonomy-gated (`requiresAutonomy: true`, default OFF) on top of that; a second consent gate asks the same person the same question twice. So: `GO_LIVE_REQUIRED` now MIRRORS go-live's `REQUIRED_FIELDS`, with a test reading BOTH source files and failing on drift; `deployTimer` moves to the optional-field loop so a co-tenant OMITS the key rather than carrying a null; `rawTopologyFor` carries `pinMode`; `resolveGoLive` gains a `needsCoTenant` probe leg; and `goLiveEngineFor()` makes the decision ONCE for both the dry run and the apply path, which had re-derived it separately. The pin net now obeys the same `pinMode` go-live was given — `commitPin()` takes `push` (default true, so the platform instance is unchanged), `local-commit` commits without pushing, `leave-dirty` skips the net, the rollback tidy follows the same rule, and the "not durable" advice stops telling an operator to `git push` a repo we do not own; its refusal to touch a worktree carrying NON-pin changes is unchanged in every mode. **The probe is CONDITIONAL on purpose** — demanding `pinMode` of every target would strip the go-live path from any instance between the `--target-json` release and 1003521's, trading a live regression for a hypothetical one. Rejected: a second consent gate (an `allowCoTenant` flag); deferring to the unspecced fleet control plane ([task 1001948](https://cloudbongos.com/builders#/task/1001948)) — which does not leave co-tenants alone, it leaves them pushing pins into customer repos; dropping `deployTimer` from `GO_LIVE_REQUIRED` and nothing else (the fix as proposed — trades a silent downgrade for a hard rejection); probing unconditionally; reaching co-tenant mode WITHOUT teaching the durability net about it (nominal support that breaks its own promise seconds later); and teaching `loadSubscriptions()` about `pinMode` instead, since that normalizer ships in the control plane's vendored core that nobody bumps — the exact trap `rawTopologyFor` routes around.](<redacted>.md) | core distribution / unattended deploy |
|
|
@@ -1821,5 +1821,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1821
1821
|
landed since 1.19.679 with no explicit bump. run 34665216511. (task 1002620)
|
|
1822
1822
|
1.19.681 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1823
1823
|
landed since 1.19.680 with no explicit bump. run 34666648354. (task 1002620)
|
|
1824
|
+
1.19.682 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1825
|
+
landed since 1.19.681 with no explicit bump. run 34704993368. (task 1002620)
|
|
1826
|
+
1.19.683 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1827
|
+
landed since 1.19.682 with no explicit bump. run 34706984747. (task 1002620)
|
|
1824
1828
|
---------------------------------------------------------------------------
|
|
1825
1829
|
```
|
|
@@ -311,6 +311,19 @@ const GENERATOR_SCRIPTS = [
|
|
|
311
311
|
// burned the attempt cap, and announceStrand flagged the task needs_rebase
|
|
312
312
|
// (PR #536 / task 1003544).
|
|
313
313
|
'scripts/gds/copy-inventory.js',
|
|
314
|
+
// task 1003537: the generated API contract + client. gen-api-docs.js writes
|
|
315
|
+
// docs/api/openapi.json + docs/api-reference.md + docs/routes-permissions.md from a
|
|
316
|
+
// scan of the route files; gen-api-client.js then writes the six
|
|
317
|
+
// clients/bongos-client files FROM that spec. Both are `--check` gates on the merge
|
|
318
|
+
// ref, so the same trap copy-inventory.js fell into applies verbatim: covered by the
|
|
319
|
+
// merge driver, regenerated by nobody, and the resulting staleness reported as
|
|
320
|
+
// `no_regen_diff` — "a real check failure" — rather than as drift.
|
|
321
|
+
//
|
|
322
|
+
// ORDER IS LOAD-BEARING: gen-api-client.js reads docs/api/openapi.json off disk, so
|
|
323
|
+
// it must run AFTER gen-api-docs.js has rewritten it for the merged tree. This list
|
|
324
|
+
// is walked in order, one await at a time, which is what makes that safe.
|
|
325
|
+
'scripts/gds/gen-api-docs.js',
|
|
326
|
+
'scripts/gds/gen-api-client.js',
|
|
314
327
|
];
|
|
315
328
|
|
|
316
329
|
// Run the CLONE's generator scripts in WRITE mode against the merged working tree.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.683",
|
|
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.683",
|
|
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.683",
|
|
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",
|
|
@@ -487,8 +487,13 @@ function renderReadme(spec, ops) {
|
|
|
487
487
|
].join('\n');
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
-
|
|
491
|
-
|
|
490
|
+
// injectedSpec (task 1003537): the otb-regen merge driver regenerates this client
|
|
491
|
+
// DURING a merge, when docs/api/openapi.json on disk may be the conflicted file git has
|
|
492
|
+
// just written markers into — JSON.parse would throw on it. Passing a spec built in
|
|
493
|
+
// memory keeps the heal working and keeps the client consistent with the api-reference.md
|
|
494
|
+
// the same merge resolves. Omitted everywhere else, so the CLI path is unchanged.
|
|
495
|
+
function build(injectedSpec) {
|
|
496
|
+
const spec = injectedSpec || JSON.parse(fs.readFileSync(SPEC_PATH, 'utf8'));
|
|
492
497
|
const ops = buildModel(spec);
|
|
493
498
|
const esm = renderClientJs(spec, ops);
|
|
494
499
|
return {
|
|
@@ -54,9 +54,40 @@ const G = require('./gen-repo-map');
|
|
|
54
54
|
const FM = require('./gen-file-map');
|
|
55
55
|
const SI = require('./gen-session-index');
|
|
56
56
|
const CI = require('./copy-inventory');
|
|
57
|
+
// gen-api-docs / gen-api-client are required LAZILY, inside the two rules that use them —
|
|
58
|
+
// deliberately NOT here (task 1003537). gen-api-docs pulls src/bongos/routes/_helpers.js
|
|
59
|
+
// for its LIMITS caps, and ship.js requires this module, so an eager require put a server
|
|
60
|
+
// route file into the boot of every `bongos` subcommand. tests/module_api_lazy.mjs catches
|
|
61
|
+
// exactly that. require() is cached, so the merge path pays the load once and the CLI
|
|
62
|
+
// path never pays it at all.
|
|
57
63
|
|
|
58
64
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
59
65
|
|
|
66
|
+
// The generated API contract (task 1003537). gen-api-docs.js writes all three of these
|
|
67
|
+
// wholesale from a scan of the route files, so one build() call answers whichever of
|
|
68
|
+
// them git hands us.
|
|
69
|
+
const API_DOC_FILES = new Set([
|
|
70
|
+
'docs/api/openapi.json',
|
|
71
|
+
'docs/api-reference.md',
|
|
72
|
+
'docs/routes-permissions.md',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
// The generated client (task 1003537), written wholesale from the spec by
|
|
76
|
+
// gen-api-client.js. These six names mirror the keys of its build().files; the
|
|
77
|
+
// lockstep is asserted in tests/git_merge_regen.mjs rather than derived here, because
|
|
78
|
+
// deriving it would mean running the generator at require time. NOTE what is absent:
|
|
79
|
+
// clients/bongos-client/examples/hello-world.mjs is hand-written and must stay out of
|
|
80
|
+
// this set, or the driver would force a conflict on a file git can merge by itself.
|
|
81
|
+
const API_CLIENT_DIR = 'clients/bongos-client';
|
|
82
|
+
const API_CLIENT_FILES = new Set([
|
|
83
|
+
'index.mjs',
|
|
84
|
+
'index.cjs',
|
|
85
|
+
'bongos-client.global.js',
|
|
86
|
+
'index.d.ts',
|
|
87
|
+
'package.json',
|
|
88
|
+
'README.md',
|
|
89
|
+
].map((n) => `${API_CLIENT_DIR}/${n}`));
|
|
90
|
+
|
|
60
91
|
// The files this driver knows how to regenerate. docs/repo-map.md and
|
|
61
92
|
// docs/session-log-index.md are whole-file; each NESTED_DIRS CLAUDE.md is
|
|
62
93
|
// hand-written prose with one generated block; docs/file-map.md is hand-written
|
|
@@ -73,6 +104,10 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
|
73
104
|
// that each add a session-log file regenerate that block differently and ALWAYS conflict
|
|
74
105
|
// there, and nothing healed it: the driver did not know the file, and `merge=union` would be
|
|
75
106
|
// wrong for prose. Same shape as docs/file-map.md (3-way-merge the prose, re-render the block).
|
|
107
|
+
// task 1003537: the two API sets above are spread in whole — every one of those nine files
|
|
108
|
+
// is generated end to end, so they take the docs/repo-map.md path (ignore all three merge
|
|
109
|
+
// sides, rebuild). They are named up there rather than inline here because the rules below
|
|
110
|
+
// test set membership, and tests/git_merge_regen.mjs cross-checks them against .gitattributes.
|
|
76
111
|
const REGENERABLE = new Set([
|
|
77
112
|
'CLAUDE.md',
|
|
78
113
|
'docs/repo-map.md',
|
|
@@ -81,6 +116,8 @@ const REGENERABLE = new Set([
|
|
|
81
116
|
'docs/copy-inventory.md',
|
|
82
117
|
'docs/copy-registry.json',
|
|
83
118
|
...G.NESTED_DIRS.map((d) => `${d}/CLAUDE.md`),
|
|
119
|
+
...API_DOC_FILES,
|
|
120
|
+
...API_CLIENT_FILES,
|
|
84
121
|
]);
|
|
85
122
|
|
|
86
123
|
// A line that begins with a git conflict marker. If any survives our resolution,
|
|
@@ -137,6 +174,33 @@ function regenerateResolved(relpath, { base = '', ours = '', theirs = '' } = {})
|
|
|
137
174
|
return { content: CI.renderReport(CI.buildRegistry()), ok: true };
|
|
138
175
|
}
|
|
139
176
|
|
|
177
|
+
// The generated API contract (task 1003537): gen-api-docs.js writes openapi.json,
|
|
178
|
+
// api-reference.md and routes-permissions.md wholesale from a scan of the route files
|
|
179
|
+
// — nothing hand-authored in any of them — so like docs/repo-map.md below they ignore
|
|
180
|
+
// all three merge sides and rebuild. One build() yields all three; take the one asked
|
|
181
|
+
// for. This is the rule that ends the task-1002334 stall, where four merge → reship
|
|
182
|
+
// cycles each lost the race to a fresh textual conflict in exactly these files.
|
|
183
|
+
if (API_DOC_FILES.has(relpath)) {
|
|
184
|
+
const AD = require('./gen-api-docs'); // lazy — see the require block up top
|
|
185
|
+
const f = AD.build().files.find((x) => x.rel === relpath);
|
|
186
|
+
return { content: f.content, ok: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The generated client (task 1003537): gen-api-client.js renders all six files from
|
|
190
|
+
// the spec, so it heals the same way. Hand it a FRESHLY BUILT spec instead of letting
|
|
191
|
+
// build() read docs/api/openapi.json off disk — mid-merge that file is very often the
|
|
192
|
+
// one git has just written conflict markers into, and JSON.parse would throw on them
|
|
193
|
+
// (an uncaught throw here exits non-zero, which git reads as "unresolved", so the
|
|
194
|
+
// failure would be a silent fallback to a textual conflict on the very files this
|
|
195
|
+
// rule exists to heal). Rebuilding also keeps the client consistent with the
|
|
196
|
+
// api-reference.md the same merge is healing.
|
|
197
|
+
if (API_CLIENT_FILES.has(relpath)) {
|
|
198
|
+
const AD = require('./gen-api-docs'); // lazy — see the require block up top
|
|
199
|
+
const AC = require('./gen-api-client'); // lazy — same reason
|
|
200
|
+
const { files } = AC.build(AD.buildOpenapi(AD.extractModel()));
|
|
201
|
+
return { content: files[relpath.slice(API_CLIENT_DIR.length + 1)], ok: true };
|
|
202
|
+
}
|
|
203
|
+
|
|
140
204
|
// The ROOT CLAUDE.md (task 1001431): hand-written prose carrying ONE generated block, the
|
|
141
205
|
// §13 session-log snippet. 3-way-merge the prose, then re-render the snippet from the
|
|
142
206
|
// session-log files on disk. Must be handled BEFORE the nested-CLAUDE.md fallthrough below,
|
package/scripts/gds/init.js
CHANGED
|
@@ -943,7 +943,7 @@ async function main(argv) {
|
|
|
943
943
|
console.error(`init: could not derive the core version from ${path.basename(tgz)} — expected a tarball named ${upgrade.ARTIFACT}-<version>.tgz (produced by \`node scripts/gds/package-core.js\`).`);
|
|
944
944
|
return 1;
|
|
945
945
|
}
|
|
946
|
-
const vendorRel =
|
|
946
|
+
const vendorRel = upgrade.vendorRelFor(version);
|
|
947
947
|
coreDep = `file:${vendorRel}`;
|
|
948
948
|
vendorPlan = { tgz, version, vendorRel };
|
|
949
949
|
}
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -34,6 +34,16 @@ const { writeNpmrc, hasNpmToken, resolveNpmToken } = require('./npmrc'); // ADR
|
|
|
34
34
|
|
|
35
35
|
const CORE_PKG = '@bongos/core';
|
|
36
36
|
const ARTIFACT = 'bongos-core';
|
|
37
|
+
// WHERE THE VENDORED TARBALL SITS, as a package.json `file:` specifier — and the reason
|
|
38
|
+
// this is not a path.join(). The same string is spent two ways: it is joined onto an
|
|
39
|
+
// absolute dir for an fs check (path.join normalises a forward slash on every platform,
|
|
40
|
+
// so that direction is free), and it is WRITTEN INTO package.json as `file:<vendorRel>`.
|
|
41
|
+
// npm specifiers are POSIX. A path.join() here emits `file:vendor\\bongos-core-x.y.z.tgz`
|
|
42
|
+
// on a Windows builder's machine, and that pin is then COMMITTED to the instance repo —
|
|
43
|
+
// where the provisioner runs `npm ci` on Linux and reads a literal backslash in the
|
|
44
|
+
// filename. One copy, POSIX by construction, so a scaffold cannot be platform-stamped
|
|
45
|
+
// by the box that happened to run the scaffolder (task 1003839).
|
|
46
|
+
function vendorRelFor(version) { return path.posix.join('vendor', `${ARTIFACT}-${version}.tgz`); }
|
|
37
47
|
|
|
38
48
|
// ---- pure helpers (no I/O side effects beyond the injected fs) --------------
|
|
39
49
|
|
|
@@ -245,7 +255,7 @@ function vendorTarball({ instanceDir, fromTgz, targetVersion }, fsImpl = fs) {
|
|
|
245
255
|
if (fsImpl.existsSync(fromManifest)) {
|
|
246
256
|
fsImpl.copyFileSync(fromManifest, path.join(vendorDir, `${ARTIFACT}-${targetVersion}.manifest.json`));
|
|
247
257
|
}
|
|
248
|
-
return path.join('vendor', destName);
|
|
258
|
+
return path.posix.join('vendor', destName);
|
|
249
259
|
}
|
|
250
260
|
|
|
251
261
|
// Lightweight module-compat pre-check reusing module.js against the TARGET core version. Best-effort:
|
|
@@ -940,10 +950,10 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
940
950
|
if (!dryRun) { writeNpmrc(instanceDir, { fsImpl, overwrite: false, log }); snapshot = { prevPin, prevVersion: fromVersion }; }
|
|
941
951
|
log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → ${targetVersion} (registry)${dryRun ? ' + write an env-fed .npmrc' : ''}${prevPin ? ` (was ${prevPin})` : ''}`);
|
|
942
952
|
} else {
|
|
943
|
-
let vendorRel = opts.pinPath || (opts.from ? null :
|
|
953
|
+
let vendorRel = opts.pinPath || (opts.from ? null : vendorRelFor(targetVersion));
|
|
944
954
|
if (opts.from) {
|
|
945
955
|
if (!fsImpl.existsSync(opts.from)) return { ok: false, error: `--from tarball not found: ${opts.from}` };
|
|
946
|
-
if (dryRun) { log(` [dry-run] would vendor ${opts.from} → vendor/${ARTIFACT}-${targetVersion}.tgz`); vendorRel =
|
|
956
|
+
if (dryRun) { log(` [dry-run] would vendor ${opts.from} → vendor/${ARTIFACT}-${targetVersion}.tgz`); vendorRel = vendorRelFor(targetVersion); }
|
|
947
957
|
else vendorRel = vendorTarball({ instanceDir, fromTgz: path.resolve(opts.from), targetVersion }, fsImpl);
|
|
948
958
|
}
|
|
949
959
|
if (!fsImpl.existsSync(path.join(instanceDir, vendorRel)) && !dryRun) {
|
|
@@ -1225,7 +1235,7 @@ module.exports = {
|
|
|
1225
1235
|
versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
|
|
1226
1236
|
vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
|
|
1227
1237
|
readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
|
|
1228
|
-
CORE_PKG, ARTIFACT,
|
|
1238
|
+
CORE_PKG, ARTIFACT, vendorRelFor,
|
|
1229
1239
|
};
|
|
1230
1240
|
|
|
1231
1241
|
if (require.main === module) {
|
package/scripts/gds/worktree.js
CHANGED
|
@@ -31,6 +31,7 @@ const fs = require('node:fs');
|
|
|
31
31
|
const path = require('node:path');
|
|
32
32
|
const { execFileSync } = require('node:child_process');
|
|
33
33
|
const { arg, hasFlag } = require('./cli-lib');
|
|
34
|
+
const { registerMergeDriver, MERGE_DRIVER_NAME } = require('./install-git-hooks');
|
|
34
35
|
|
|
35
36
|
// -- pure helpers (exported for tests/worktree_helper.mjs) --------------------
|
|
36
37
|
|
|
@@ -145,6 +146,25 @@ function cmdAdd(root, argv) {
|
|
|
145
146
|
process.exit(1);
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
// Register the otb-regen merge driver (task 1003537). .gitattributes routes the
|
|
150
|
+
// generated files through it, but git honours that ONLY where merge.otb-regen.driver is
|
|
151
|
+
// configured — which install-git-hooks.js does per CLONE, on /builder-setup. A builder
|
|
152
|
+
// who never ran the installer got a silent TEXTUAL merge of files no textual merge can
|
|
153
|
+
// resolve: measured on task 1002334, a scripts/gds/CLAUDE.md symbol block matching
|
|
154
|
+
// neither generator, clean enough that nothing flagged it until fitness ran in CI.
|
|
155
|
+
// Worktree-per-claim is the default path to a new tree, so doing it here is what closes
|
|
156
|
+
// that gap for a clone whose owner never ran setup. git config is shared with every
|
|
157
|
+
// linked worktree, so registering against the main root covers this tree and all later
|
|
158
|
+
// ones. Idempotent, and best-effort on purpose: failing to register only restores the
|
|
159
|
+
// pre-existing fallback-to-conflict behaviour, which must never fail `worktree add`.
|
|
160
|
+
const driver = registerMergeDriver(root);
|
|
161
|
+
if (driver.ok) {
|
|
162
|
+
console.log(` merge driver '${MERGE_DRIVER_NAME}' registered (generated files auto-regenerate on merge)`);
|
|
163
|
+
} else {
|
|
164
|
+
console.log(` ⚠ could not register the '${MERGE_DRIVER_NAME}' merge driver: ${driver.error}`);
|
|
165
|
+
console.log(' (generated files will merge textually here; ship.js still self-heals the same set)');
|
|
166
|
+
}
|
|
167
|
+
|
|
148
168
|
if (needsJunction({ wtPath, repoRoot: root, platform: process.platform })) {
|
|
149
169
|
const target = path.join(root, 'node_modules');
|
|
150
170
|
if (!fs.existsSync(target)) {
|
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.683'; // 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');
|
|
@@ -562,6 +562,19 @@ test('1003546 every REGENERABLE artifact has a producer, and every producer is i
|
|
|
562
562
|
[CI.REGISTRY_REL, 'scripts/gds/copy-inventory.js'],
|
|
563
563
|
[CI.REPORT_REL, 'scripts/gds/copy-inventory.js'],
|
|
564
564
|
...G.NESTED_DIRS.map((d) => [`${d}/CLAUDE.md`, 'scripts/gds/gen-repo-map.js']),
|
|
565
|
+
// task 1003537 — the generated API contract + client joined REGENERABLE. Mapped by
|
|
566
|
+
// PATH PREFIX rather than by calling the generators: this suite deliberately sets
|
|
567
|
+
// *_INSTANCE_ROOT (see the env-hardening case above), which repoints their docs root
|
|
568
|
+
// at a fake server checkout, so a build() here reads a file that does not exist.
|
|
569
|
+
// Nothing is lost by not running them — drift between REGENERABLE and what they
|
|
570
|
+
// actually write is pinned by tests/git_merge_regen.mjs case 11, and a future entry
|
|
571
|
+
// under either prefix is picked up here automatically.
|
|
572
|
+
...[...regen.REGENERABLE]
|
|
573
|
+
.filter((p) => p.startsWith('docs/api/') || p === 'docs/api-reference.md' || p === 'docs/routes-permissions.md')
|
|
574
|
+
.map((p) => [p, 'scripts/gds/gen-api-docs.js']),
|
|
575
|
+
...[...regen.REGENERABLE]
|
|
576
|
+
.filter((p) => p.startsWith('clients/bongos-client/'))
|
|
577
|
+
.map((p) => [p, 'scripts/gds/gen-api-client.js']),
|
|
565
578
|
]);
|
|
566
579
|
|
|
567
580
|
for (const p of regen.REGENERABLE) {
|
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
// the merge's OWNER must regenerate afterwards (conflict-resolve.js step 7b).
|
|
29
29
|
// Needs a real clone (the driver's __dirname must BE the merging tree), so it
|
|
30
30
|
// skips where the environment cannot host that.
|
|
31
|
+
// 11. The generated API contract + client (task 1003537) — docs/api/openapi.json,
|
|
32
|
+
// docs/api-reference.md, docs/routes-permissions.md and the six
|
|
33
|
+
// clients/bongos-client files regenerate clean from the merged tree, the client
|
|
34
|
+
// heals even when the on-disk spec is the conflicted file, and the driver's file
|
|
35
|
+
// list still matches what gen-api-client actually writes. Also pins the ABSENCE of
|
|
36
|
+
// clients/bongos-client/examples/hello-world.mjs, which is hand-written.
|
|
31
37
|
//
|
|
32
38
|
// Run: node tests/git_merge_regen.mjs
|
|
33
39
|
|
|
@@ -46,6 +52,8 @@ const regen = require('../scripts/gds/git-merge-regen.js');
|
|
|
46
52
|
const G = require('../scripts/gds/gen-repo-map.js');
|
|
47
53
|
const CI = require('../scripts/gds/copy-inventory.js');
|
|
48
54
|
const SI = require('../scripts/gds/gen-session-index.js'); // task 1001431 — the root CLAUDE.md's §13 snippet
|
|
55
|
+
const AD = require('../scripts/gds/gen-api-docs.js'); // task 1003537 — the generated API contract
|
|
56
|
+
const AC = require('../scripts/gds/gen-api-client.js'); // task 1003537 — the generated client
|
|
49
57
|
|
|
50
58
|
const MARKERS = /^(<{7}|={7}|>{7})/m;
|
|
51
59
|
|
|
@@ -438,4 +446,56 @@ function withBlockBody(src, body) {
|
|
|
438
446
|
}
|
|
439
447
|
}
|
|
440
448
|
|
|
449
|
+
// ── 11) the generated API contract + client (task 1003537) ─────────────────────
|
|
450
|
+
// gen-api-docs.js and gen-api-client.js write nine fully generated files that used to
|
|
451
|
+
// merge textually, so any branch touching a route conflicted with every concurrent land
|
|
452
|
+
// (task 1002334: four merge → reship cycles, each beaten by a fresh conflict). They heal
|
|
453
|
+
// like docs/repo-map.md — all three merge sides ignored, rebuilt from the merged tree.
|
|
454
|
+
{
|
|
455
|
+
const conflicted = (a, b) => `<<<<<<< ours\n${a}\n=======\n${b}\n>>>>>>> theirs\n`;
|
|
456
|
+
|
|
457
|
+
// (a) the three doc artifacts rebuild to exactly what the generator would write.
|
|
458
|
+
const fresh = new Map(AD.build().files.map((f) => [f.rel, f.content]));
|
|
459
|
+
for (const rel of ['docs/api/openapi.json', 'docs/api-reference.md', 'docs/routes-permissions.md']) {
|
|
460
|
+
const r = regen.regenerateResolved(rel, {
|
|
461
|
+
base: 'base\n', ours: conflicted('{"a":1}', '{"b":2}'), theirs: 'nonsense\n',
|
|
462
|
+
});
|
|
463
|
+
assert.equal(r.ok, true, `${rel} heal must succeed`);
|
|
464
|
+
assert.ok(!MARKERS.test(r.content), `${rel} heal must leave no conflict markers`);
|
|
465
|
+
assert.equal(r.content, fresh.get(rel), `${rel} heal must equal a fresh generation`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// (b) the six client files do too. The driver rebuilds the spec in memory rather than
|
|
469
|
+
// reading docs/api/openapi.json, so this must hold no matter what is on disk.
|
|
470
|
+
const freshClient = AC.build().files;
|
|
471
|
+
for (const [name, content] of Object.entries(freshClient)) {
|
|
472
|
+
const rel = `clients/bongos-client/${name}`;
|
|
473
|
+
const r = regen.regenerateResolved(rel, {
|
|
474
|
+
base: 'base\n', ours: conflicted('one', 'two'), theirs: 'nonsense\n',
|
|
475
|
+
});
|
|
476
|
+
assert.equal(r.ok, true, `${rel} heal must succeed`);
|
|
477
|
+
assert.ok(!MARKERS.test(r.content), `${rel} heal must leave no conflict markers`);
|
|
478
|
+
assert.equal(r.content, content, `${rel} heal must equal a fresh generation`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// (c) LOCKSTEP: the driver's hardcoded client list must be exactly what the generator
|
|
482
|
+
// writes. Without this, a seventh generated client file would be added to
|
|
483
|
+
// gen-api-client.js and silently fall back to a textual merge — the original defect.
|
|
484
|
+
const declared = [...regen.REGENERABLE]
|
|
485
|
+
.filter((f) => f.startsWith('clients/bongos-client/'))
|
|
486
|
+
.map((f) => f.slice('clients/bongos-client/'.length))
|
|
487
|
+
.sort();
|
|
488
|
+
assert.deepEqual(declared, Object.keys(freshClient).sort(),
|
|
489
|
+
'REGENERABLE\'s clients/bongos-client entries must match gen-api-client build().files exactly');
|
|
490
|
+
|
|
491
|
+
// (d) the hand-written example must stay OUT. A `clients/bongos-client/*` glob would
|
|
492
|
+
// have swept it in, and the driver has no rule for it — so git would be forced into a
|
|
493
|
+
// conflict on a file it can merge by itself (the /CLAUDE.md leading-slash trap again).
|
|
494
|
+
assert.ok(!regen.REGENERABLE.has('clients/bongos-client/examples/hello-world.mjs'),
|
|
495
|
+
'the hand-written example must never be routed through the regenerating driver');
|
|
496
|
+
assert.equal(regen.regenerateResolved('clients/bongos-client/examples/hello-world.mjs',
|
|
497
|
+
{ base: 'a\n', ours: 'b\n', theirs: 'c\n' }).content, null,
|
|
498
|
+
'a non-generated path under the client dir must not resolve');
|
|
499
|
+
}
|
|
500
|
+
|
|
441
501
|
console.log('git_merge_regen: all assertions passed');
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// tests/update_subscription_engine.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003843 — the nightly update lane can drive a CO-TENANT.
|
|
4
|
+
//
|
|
5
|
+
// `.claude/scheduled-tasks/core-update-subscription/subscribe.js` decides, per roster
|
|
6
|
+
// entry, whether the bump goes through go-live.js (backup + timer guard + box-side
|
|
7
|
+
// read-back) or falls back to a bare `bongos upgrade`. That decision reads a second,
|
|
8
|
+
// hand-maintained copy of go-live's own target contract — and a copy drifts:
|
|
9
|
+
//
|
|
10
|
+
// 1. CONTRACT DRIFT. go-live made `deployTimer` optional for co-tenant mode (task
|
|
11
|
+
// 1003521); GO_LIVE_REQUIRED here kept requiring it, so every provisioned
|
|
12
|
+
// co-tenant was told its entry "lacks deployTimer" and silently took the weaker
|
|
13
|
+
// path forever. The first test pins the two lists TOGETHER, at the source, so the
|
|
14
|
+
// next move of go-live's contract fails here instead of degrading a live deploy.
|
|
15
|
+
// 2. A NULL IS NOT AN ABSENT FIELD. The normalizer yields `deployTimer: null`, and
|
|
16
|
+
// that null survives JSON.stringify into --target-json, where go-live's
|
|
17
|
+
// validateTarget runs isSafeToken(null) and rejects the whole target. Dropping
|
|
18
|
+
// the field from GO_LIVE_REQUIRED alone would have traded a silent downgrade for
|
|
19
|
+
// a hard failure, so the target must OMIT the key, not carry a null.
|
|
20
|
+
// 3. THE STALE NORMALIZER, ONE FIELD LATER. rawTopologyFor reads topology straight
|
|
21
|
+
// off disk precisely because the control plane's vendored normalizer drops fields
|
|
22
|
+
// it predates. `pinMode` was the next field it predated.
|
|
23
|
+
// 4. FAIL-CLOSED, BUT NOT OVER-CLOSED. An older go-live.js REJECTS a co-tenant target
|
|
24
|
+
// outright, so the capability probe must refuse it — while an instance on a
|
|
25
|
+
// mid-generation core must KEEP the go-live path it already has for ordinary
|
|
26
|
+
// targets.
|
|
27
|
+
//
|
|
28
|
+
// Every external is injected (readFile, resolveFrom) or written to a temp dir, so this
|
|
29
|
+
// is DB-free, network-free and platform-independent. The source-reading test comes
|
|
30
|
+
// FIRST: it is the half that pins the cross-file contract, and it must run even if a
|
|
31
|
+
// later case trips.
|
|
32
|
+
//
|
|
33
|
+
// Run: node --test tests/update_subscription_engine.mjs
|
|
34
|
+
|
|
35
|
+
import { test } from 'node:test';
|
|
36
|
+
import assert from 'node:assert/strict';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
39
|
+
import { tmpdir } from 'node:os';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
45
|
+
const S = require('../.claude/scheduled-tasks/core-update-subscription/subscribe.js');
|
|
46
|
+
|
|
47
|
+
// A roster entry as loadSubscriptions() normalizes it: absent topology is an explicit
|
|
48
|
+
// null, which is the shape that makes case 2 above possible.
|
|
49
|
+
function entry(over = {}) {
|
|
50
|
+
return {
|
|
51
|
+
slug: 'demo', dir: '/srv/demo', service: 'demo.service',
|
|
52
|
+
healthUrl: 'http://127.0.0.1:3002/healthz', channel: 'patch',
|
|
53
|
+
versionUrl: null, deployTimer: null, deployService: null,
|
|
54
|
+
backupDb: null, backupDir: null, registryPackage: null, env: {},
|
|
55
|
+
...over,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Stand-ins for the two generations of go-live.js the probe has to tell apart. What
|
|
60
|
+
// matters is only which capability markers the SOURCE carries.
|
|
61
|
+
const GO_LIVE_OLD = 'usage: go-live.js --target-json <json> --to <version>\n';
|
|
62
|
+
const GO_LIVE_NEW = `${GO_LIVE_OLD}const OPTIONAL_FIELDS = ['deployTimer', 'pinMode'];\n`;
|
|
63
|
+
const GO_LIVE_ANCIENT = 'usage: go-live.js --target <name> // ssh only, no local mode\n';
|
|
64
|
+
|
|
65
|
+
function rosterDir(rows) {
|
|
66
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'subscribe-engine-'));
|
|
67
|
+
mkdirSync(path.join(dir, 'config'), { recursive: true });
|
|
68
|
+
writeFileSync(path.join(dir, 'config', 'update-subscriptions.json'), JSON.stringify({ instances: rows }));
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// 1. the cross-file contract — STATIC, reads both sources
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
test('GO_LIVE_REQUIRED mirrors go-live.js REQUIRED_FIELDS exactly', () => {
|
|
77
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
|
|
78
|
+
const m = src.match(/const REQUIRED_FIELDS = \[([^\]]*)\]/);
|
|
79
|
+
assert.ok(m, 'could not find REQUIRED_FIELDS in scripts/gds/go-live.js');
|
|
80
|
+
const required = m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(
|
|
83
|
+
[...S.GO_LIVE_REQUIRED].sort(), [...required].sort(),
|
|
84
|
+
'subscribe.js keeps its own copy of go-live\'s required fields; they have drifted apart '
|
|
85
|
+
+ '— a roster entry go-live would accept is being refused here (or the reverse)',
|
|
86
|
+
);
|
|
87
|
+
// Named outright, so the regression that started this task cannot come back quietly.
|
|
88
|
+
assert.ok(!S.GO_LIVE_REQUIRED.includes('deployTimer'), 'deployTimer is OPTIONAL — requiring it locks out every co-tenant');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('go-live.js still accepts the pinMode the lane now sends', () => {
|
|
92
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
|
|
93
|
+
const m = src.match(/const OPTIONAL_FIELDS = \[([^\]]*)\]/);
|
|
94
|
+
assert.ok(m, 'could not find OPTIONAL_FIELDS in scripts/gds/go-live.js');
|
|
95
|
+
// go-live reports UNKNOWN keys as errors rather than ignoring them, so sending a field
|
|
96
|
+
// it does not list is a hard rejection of the whole target, not a no-op.
|
|
97
|
+
assert.ok(m[1].includes("'pinMode'"), 'go-live no longer lists pinMode — the lane would now be sending an unknown field');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// 2. the target a co-tenant produces
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
test('a co-tenant entry maps to a valid target: deployTimer is OMITTED, never null', () => {
|
|
105
|
+
const t = S.goLiveTargetFor(entry(), { versionUrl: 'http://127.0.0.1:3002/version', pinMode: 'local-commit' });
|
|
106
|
+
assert.equal(t.ok, true, `co-tenant entry should map; missing: ${JSON.stringify(t.missing)}`);
|
|
107
|
+
assert.equal('deployTimer' in t.target, false, 'the KEY must be absent — a null reaches go-live as an unsafe token and fails validateTarget');
|
|
108
|
+
// The wire form is what go-live actually sees.
|
|
109
|
+
assert.equal(JSON.parse(JSON.stringify(t.target)).deployTimer, undefined);
|
|
110
|
+
assert.equal(t.target.pinMode, 'local-commit');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a classic entry still carries its deployTimer', () => {
|
|
114
|
+
const t = S.goLiveTargetFor(entry({ deployTimer: 'demo-deploy.timer' }), { versionUrl: 'http://127.0.0.1:3002/version' });
|
|
115
|
+
assert.equal(t.ok, true);
|
|
116
|
+
assert.equal(t.target.deployTimer, 'demo-deploy.timer');
|
|
117
|
+
assert.equal('pinMode' in t.target, false, 'an entry that names no pinMode must not invent one — go-live defaults it to push');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a genuinely incomplete entry still reports what is missing', () => {
|
|
121
|
+
const t = S.goLiveTargetFor(entry({ healthUrl: null }), {});
|
|
122
|
+
assert.equal(t.ok, false);
|
|
123
|
+
assert.deepEqual(t.missing, ['healthUrl', 'versionUrl']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('rawTopologyFor reads pinMode off disk', () => {
|
|
127
|
+
const dir = rosterDir([{ slug: 'demo', dir: '/srv/demo', versionUrl: 'http://x/version', pinMode: 'leave-dirty' }]);
|
|
128
|
+
const raw = S.rawTopologyFor(dir, 'demo');
|
|
129
|
+
assert.equal(raw.pinMode, 'leave-dirty', 'pinMode is dropped before the target is built — the stale-normalizer trap, one field later');
|
|
130
|
+
assert.equal(raw.versionUrl, 'http://x/version');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// 3. which targets need co-tenant support
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
test('needsCoTenantMode: no deployTimer, or any pinMode', () => {
|
|
138
|
+
assert.equal(S.needsCoTenantMode({ deployTimer: 'd.timer' }), false);
|
|
139
|
+
assert.equal(S.needsCoTenantMode({}), true, 'no deployTimer is co-tenant mode');
|
|
140
|
+
assert.equal(S.needsCoTenantMode({ deployTimer: 'd.timer', pinMode: 'local-commit' }), true, 'pinMode is an unknown field to an older go-live');
|
|
141
|
+
assert.equal(S.needsCoTenantMode(null), false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// 4. the capability probe — fail-closed, but not over-closed
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const probe = (source, opts) => S.resolveGoLive('/srv/demo', {
|
|
149
|
+
resolveFrom: () => '/srv/demo/node_modules/@bongos/core/scripts/gds/go-live.js',
|
|
150
|
+
readFile: () => source,
|
|
151
|
+
...opts,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('probe: a co-tenant target refuses a go-live.js without pinMode', () => {
|
|
155
|
+
assert.equal(probe(GO_LIVE_NEW, { needsCoTenant: true }), '/srv/demo/node_modules/@bongos/core/scripts/gds/go-live.js');
|
|
156
|
+
assert.equal(probe(GO_LIVE_OLD, { needsCoTenant: true }), null, 'that go-live would reject the target on a validation error instead of falling back');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('probe: an ORDINARY target still runs on a mid-generation core', () => {
|
|
160
|
+
// The regression to avoid: probing for pinMode unconditionally would strip the
|
|
161
|
+
// go-live path from every instance sitting between the two releases.
|
|
162
|
+
assert.equal(probe(GO_LIVE_OLD, { needsCoTenant: false }), '/srv/demo/node_modules/@bongos/core/scripts/gds/go-live.js');
|
|
163
|
+
assert.equal(probe(GO_LIVE_ANCIENT, { needsCoTenant: false }), null, 'no --target-json: the pre-existing local-mode gate still holds');
|
|
164
|
+
assert.equal(probe(GO_LIVE_ANCIENT, { needsCoTenant: true }), null);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('probe: an unreadable or unresolvable go-live falls back rather than throwing', () => {
|
|
168
|
+
assert.equal(S.resolveGoLive('/srv/demo', { resolveFrom: () => null, readFile: () => GO_LIVE_NEW }), null);
|
|
169
|
+
assert.equal(S.resolveGoLive('/srv/demo', {
|
|
170
|
+
resolveFrom: () => '/srv/demo/go-live.js',
|
|
171
|
+
readFile: () => { throw new Error('EACCES'); },
|
|
172
|
+
}), null);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// 5. end to end: roster entry → engine
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
const engine = (rows, inst, source) => S.goLiveEngineFor(inst, rosterDir(rows), {
|
|
180
|
+
resolveFrom: () => '/srv/demo/node_modules/@bongos/core/scripts/gds/go-live.js',
|
|
181
|
+
readFile: () => source,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a co-tenant on a current core is driven by go-live, pinMode and all', () => {
|
|
185
|
+
const row = { slug: 'demo', dir: '/srv/demo', versionUrl: 'http://127.0.0.1:3002/version', pinMode: 'local-commit' };
|
|
186
|
+
const e = engine([row], entry(), GO_LIVE_NEW);
|
|
187
|
+
assert.ok(e.script, `should have chosen go-live; why: ${e.why}`);
|
|
188
|
+
assert.equal(e.coTenant, true);
|
|
189
|
+
assert.equal(e.mapped.target.pinMode, 'local-commit', 'the pin must not be pushed into the customer\'s own repo');
|
|
190
|
+
assert.equal('deployTimer' in e.mapped.target, false);
|
|
191
|
+
assert.equal(e.raw.versionUrl, 'http://127.0.0.1:3002/version', 'the fallback branch reads versionUrl off this');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('a co-tenant on an older core falls back, and says why', () => {
|
|
195
|
+
const row = { slug: 'demo', dir: '/srv/demo', versionUrl: 'http://127.0.0.1:3002/version', pinMode: 'local-commit' };
|
|
196
|
+
const e = engine([row], entry(), GO_LIVE_OLD);
|
|
197
|
+
assert.equal(e.script, null);
|
|
198
|
+
assert.equal(e.coTenant, true);
|
|
199
|
+
assert.match(e.why, /co-tenant support/, `the operator has to be able to tell this apart from a malformed roster entry (got: ${e.why})`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('an entry missing topology falls back naming the fields, not the core', () => {
|
|
203
|
+
const e = engine([{ slug: 'demo', dir: '/srv/demo' }], entry(), GO_LIVE_NEW);
|
|
204
|
+
assert.equal(e.script, null);
|
|
205
|
+
assert.equal(e.coTenant, false);
|
|
206
|
+
assert.match(e.why, /roster entry lacks versionUrl/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('a classic entry on a current core is unchanged by all of this', () => {
|
|
210
|
+
const row = { slug: 'demo', dir: '/srv/demo', versionUrl: 'http://127.0.0.1:3002/version', deployTimer: 'demo-deploy.timer' };
|
|
211
|
+
const e = engine([row], entry(), GO_LIVE_NEW);
|
|
212
|
+
assert.ok(e.script);
|
|
213
|
+
assert.equal(e.coTenant, false);
|
|
214
|
+
assert.equal(e.mapped.target.deployTimer, 'demo-deploy.timer');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// 6. the pin-durability net must not fight the target's own pin policy
|
|
219
|
+
//
|
|
220
|
+
// This is the half that makes co-tenant support real rather than nominal. go-live
|
|
221
|
+
// honours pinMode correctly; the LANE then runs its own commit-and-push net over the
|
|
222
|
+
// same repo afterwards, and that net predates pinMode. Under `leave-dirty` it found the
|
|
223
|
+
// deliberately-dirty pin, committed it and PUSHED it — into the customer's own remote,
|
|
224
|
+
// which is the single thing pinMode exists to prevent.
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
// A fake git that records every invocation and reports one dirty pin file.
|
|
228
|
+
function fakeGit({ dirty = ['package.json'] } = {}) {
|
|
229
|
+
const calls = [];
|
|
230
|
+
const run = (_bin, args) => {
|
|
231
|
+
calls.push(args.slice(2)); // drop the leading -C <dir>
|
|
232
|
+
const sub = args[2];
|
|
233
|
+
if (sub === 'status') return { status: 0, stdout: dirty.map((f) => ` M ${f}`).join('\n') };
|
|
234
|
+
if (sub === 'rev-parse') return { status: 0, stdout: 'main\n' };
|
|
235
|
+
return { status: 0, stdout: '' };
|
|
236
|
+
};
|
|
237
|
+
return { run, calls, ran: (verb) => calls.some((c) => c[0] === verb) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test('pinModeOf defaults to push, exactly as go-live does', () => {
|
|
241
|
+
assert.deepEqual(S.pinModeOf({}), { mode: 'push', valid: true });
|
|
242
|
+
assert.deepEqual(S.pinModeOf(null), { mode: 'push', valid: true });
|
|
243
|
+
assert.deepEqual(S.pinModeOf({ pinMode: 'local-commit' }), { mode: 'local-commit', valid: true });
|
|
244
|
+
assert.deepEqual(S.pinModeOf({ pinMode: 'leave-dirty' }), { mode: 'leave-dirty', valid: true });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('pinModeOf REFUSES an unknown mode instead of defaulting', () => {
|
|
248
|
+
// Defaulting would send a typo to the push branch, and on a co-tenant that push lands in the
|
|
249
|
+
// customer's own repo. The safe-looking default is the dangerous one here.
|
|
250
|
+
assert.deepEqual(S.pinModeOf({ pinMode: 'local_commit' }), { mode: 'local_commit', valid: false });
|
|
251
|
+
assert.deepEqual(S.pinModeOf({ pinMode: 'leavedirty' }), { mode: 'leavedirty', valid: false });
|
|
252
|
+
assert.deepEqual(S.PIN_MODES, ['push', 'local-commit', 'leave-dirty']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('PIN_MODES mirrors go-live.js PIN_MODES exactly', () => {
|
|
256
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
|
|
257
|
+
const m = src.match(/const PIN_MODES = \[([^\]]*)\]/);
|
|
258
|
+
assert.ok(m, 'could not find PIN_MODES in scripts/gds/go-live.js');
|
|
259
|
+
const theirs = m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
|
260
|
+
assert.deepEqual([...S.PIN_MODES].sort(), [...theirs].sort(),
|
|
261
|
+
'the lane validates pinMode against its own copy of go-live\'s enum; they have drifted');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('commitPin pushes by default — the platform instance still needs that', () => {
|
|
265
|
+
const g = fakeGit();
|
|
266
|
+
const r = S.commitPin({ dir: '/srv/demo', message: 'pin' }, g.run, () => {});
|
|
267
|
+
assert.equal(r.ok, true);
|
|
268
|
+
assert.equal(r.action, 'committed');
|
|
269
|
+
assert.equal(g.ran('push'), true, 'an unpushed pin on the platform instance is reverted by the next pull-deploy reset');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('commitPin with push:false commits and STOPS', () => {
|
|
273
|
+
const g = fakeGit();
|
|
274
|
+
const r = S.commitPin({ dir: '/srv/demo', message: 'pin', push: false }, g.run, () => {});
|
|
275
|
+
assert.equal(r.ok, true);
|
|
276
|
+
assert.equal(r.action, 'committed-local');
|
|
277
|
+
assert.equal(g.ran('commit'), true, 'the pin must still be made durable locally');
|
|
278
|
+
assert.equal(g.ran('push'), false, 'the remote is the CUSTOMER\'s repo — pushing there is what pinMode forbids');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('commitPin still refuses a worktree carrying non-pin changes', () => {
|
|
282
|
+
const g = fakeGit({ dirty: ['package.json', 'src/thing.js'] });
|
|
283
|
+
const r = S.commitPin({ dir: '/srv/demo', message: 'pin', push: false }, g.run, () => {});
|
|
284
|
+
assert.equal(r.ok, false);
|
|
285
|
+
assert.equal(r.action, 'refused');
|
|
286
|
+
assert.equal(g.ran('commit'), false, 'a human\'s uncommitted work must stop the lane regardless of pin mode');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// 7. the HEAL step — the third place the pin gets written
|
|
291
|
+
//
|
|
292
|
+
// Found by the grader on round 1, and it is the worst of the three because it
|
|
293
|
+
// fires on the HAPPY path. `leave-dirty`'s steady state after a successful
|
|
294
|
+
// upgrade IS a dirty pin, so the next scheduled sweep's heal-first step saw it,
|
|
295
|
+
// called it "a leftover from an earlier sweep", and committed + pushed it into
|
|
296
|
+
// the customer's own repo — reliably, every sweep, not just after a crash.
|
|
297
|
+
//
|
|
298
|
+
// These cases drive commitPin the way main()'s heal branch does, since the heal
|
|
299
|
+
// itself is inline in main() and not separately callable.
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
test('heal under local-commit commits WITHOUT pushing', () => {
|
|
303
|
+
const g = fakeGit();
|
|
304
|
+
const r = S.commitPin({ dir: '/srv/demo', message: 'leftover pin', push: false }, g.run, () => {});
|
|
305
|
+
assert.equal(r.ok, true);
|
|
306
|
+
assert.equal(r.action, 'committed-local');
|
|
307
|
+
assert.equal(g.ran('push'), false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('heal counts a local commit as healed, not as a no-op', () => {
|
|
311
|
+
// main() increments `healed` on 'committed' OR 'committed-local'; matching only the former
|
|
312
|
+
// would under-report every co-tenant heal as if nothing happened.
|
|
313
|
+
const g = fakeGit();
|
|
314
|
+
const r = S.commitPin({ dir: '/srv/demo', message: 'leftover pin', push: false }, g.run, () => {});
|
|
315
|
+
assert.ok(['committed', 'committed-local'].includes(r.action));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('the heal branch is reachable only when the dirty files ARE the pin', () => {
|
|
319
|
+
// Unchanged by this work, re-pinned because the heal now has a third path through it.
|
|
320
|
+
const g = fakeGit({ dirty: ['package.json', 'docs/thing.md'] });
|
|
321
|
+
const r = S.commitPin({ dir: '/srv/demo', message: 'leftover pin', push: true }, g.run, () => {});
|
|
322
|
+
assert.equal(r.ok, false);
|
|
323
|
+
assert.equal(r.action, 'refused');
|
|
324
|
+
assert.equal(g.ran('commit'), false);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// 8. the whole file, read as policy
|
|
329
|
+
//
|
|
330
|
+
// The three pin-writing sites are spread across ~250 lines of main(), and the
|
|
331
|
+
// bug each time was a site that did not ask what mode it was in. A source-level
|
|
332
|
+
// assertion is the only thing that sees all three at once.
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
test('every commitPin call site in the lane decides about push', () => {
|
|
336
|
+
const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
|
|
337
|
+
// Every CALL, excluding the definition (which is `function commitPin({ ... push = true })` —
|
|
338
|
+
// its default is what these sites must not silently inherit).
|
|
339
|
+
const calls = [];
|
|
340
|
+
const re = /commitPin\(\{/g;
|
|
341
|
+
let m;
|
|
342
|
+
while ((m = re.exec(src)) !== null) {
|
|
343
|
+
if (src.slice(Math.max(0, m.index - 9), m.index) === 'function ') continue;
|
|
344
|
+
// Brace-depth, not indexOf('}') — every message here carries a `${...}` whose own brace
|
|
345
|
+
// would otherwise end the slice before `push:` is ever reached.
|
|
346
|
+
let depth = 0, end = m.index;
|
|
347
|
+
for (let i = src.indexOf('{', m.index); i < src.length; i++) {
|
|
348
|
+
if (src[i] === '{') depth++;
|
|
349
|
+
else if (src[i] === '}' && --depth === 0) { end = i + 1; break; }
|
|
350
|
+
}
|
|
351
|
+
calls.push(src.slice(m.index, end));
|
|
352
|
+
}
|
|
353
|
+
assert.ok(calls.length >= 3, `expected the three known pin-writing call sites, found ${calls.length}`);
|
|
354
|
+
for (const c of calls) {
|
|
355
|
+
assert.match(c, /push:/, `a commitPin call site does not decide about push, so it inherits the default and pushes:\n${c}`);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('the direct-upgrade fallback withholds --commit-pin unless the mode is push', () => {
|
|
360
|
+
const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
|
|
361
|
+
// --commit-pin makes upgrade.js commit AND push, so the fallback has to gate it too — the
|
|
362
|
+
// policy must not depend on which engine the instance's core happened to support.
|
|
363
|
+
assert.match(src, /if \(canCommitPin && pinMode === 'push'\) upArgs\.push\('--commit-pin'\)/,
|
|
364
|
+
'the fallback path would push a co-tenant pin through upgrade.js instead of the lane');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// 9. healDecision — the grader's blocker, now reachable by a test
|
|
369
|
+
//
|
|
370
|
+
// This is the scenario that shipped past a green suite on round 1: under
|
|
371
|
+
// `leave-dirty` the steady state after a successful upgrade IS a dirty pin, so
|
|
372
|
+
// the NEXT sweep's heal step called it a leftover and pushed it to the
|
|
373
|
+
// customer's own remote. On the happy path. Every sweep.
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
const dirtyPin = { repo: true, clean: false, pin: [{ file: 'package.json' }], other: [] };
|
|
377
|
+
const dirtyHuman = { repo: true, clean: false, pin: [], other: [{ file: 'src/thing.js' }] };
|
|
378
|
+
const cleanTree = { repo: true, clean: true, pin: [], other: [] };
|
|
379
|
+
|
|
380
|
+
test('healDecision: leave-dirty is BY DESIGN, never a leftover', () => {
|
|
381
|
+
assert.deepEqual(S.healDecision(dirtyPin, 'leave-dirty'), { action: 'by-design' });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('healDecision: local-commit heals WITHOUT pushing', () => {
|
|
385
|
+
assert.deepEqual(S.healDecision(dirtyPin, 'local-commit'), { action: 'commit', push: false });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('healDecision: the platform default still commits and pushes', () => {
|
|
389
|
+
assert.deepEqual(S.healDecision(dirtyPin, 'push'), { action: 'commit', push: true });
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('healDecision: non-pin changes halt in EVERY mode', () => {
|
|
393
|
+
// A human's uncommitted work stops the lane whatever the pin policy is — checked in all three
|
|
394
|
+
// because 'by-design' is an early return and could easily be placed ahead of this.
|
|
395
|
+
for (const mode of ['push', 'local-commit', 'leave-dirty']) {
|
|
396
|
+
assert.deepEqual(S.healDecision(dirtyHuman, mode), { action: 'halt' }, `mode ${mode} must still halt`);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('healDecision: a clean tree or a non-repo does nothing', () => {
|
|
401
|
+
assert.deepEqual(S.healDecision(cleanTree, 'push'), { action: 'nothing' });
|
|
402
|
+
assert.deepEqual(S.healDecision({ repo: false }, 'push'), { action: 'nothing' });
|
|
403
|
+
assert.deepEqual(S.healDecision(null, 'push'), { action: 'nothing' });
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('both commitPin successes report committed:true', () => {
|
|
407
|
+
// main() counts a heal off this ONE flag. Matching on action strings instead silently
|
|
408
|
+
// under-reported every co-tenant heal as if nothing had happened.
|
|
409
|
+
const a = S.commitPin({ dir: '/srv/demo', message: 'm' }, fakeGit().run, () => {});
|
|
410
|
+
const b = S.commitPin({ dir: '/srv/demo', message: 'm', push: false }, fakeGit().run, () => {});
|
|
411
|
+
assert.equal(a.committed, true);
|
|
412
|
+
assert.equal(b.committed, true);
|
|
413
|
+
assert.equal(S.commitPin({ dir: '/srv/demo', message: 'm' }, fakeGit({ dirty: [] }).run, () => {}).committed, undefined,
|
|
414
|
+
'an already-clean tree committed nothing and must not be counted as a heal');
|
|
415
|
+
});
|
package/tests/upgrade.mjs
CHANGED
|
@@ -88,11 +88,51 @@ t('vendorTarball: copies the .tgz + side-car manifest under the canonical name',
|
|
|
88
88
|
writeFileSync(join(src, 'bongos-core-1.15.0.manifest.json'), '{"v":1}');
|
|
89
89
|
const dir = scratchConsumer();
|
|
90
90
|
const rel = u.vendorTarball({ instanceDir: dir, fromTgz: join(src, 'bongos-core-1.15.0.tgz'), targetVersion: '1.15.0' });
|
|
91
|
-
|
|
91
|
+
// POSIX outright, not join(): this return value is written into package.json as a
|
|
92
|
+
// `file:` specifier, so the separator is part of the contract, not of the platform
|
|
93
|
+
assert.equal(rel, 'vendor/bongos-core-1.15.0.tgz');
|
|
92
94
|
assert.equal(readFileSync(join(dir, rel), 'utf8'), 'TGZBYTES');
|
|
93
95
|
assert.ok(existsSync(join(dir, 'vendor', 'bongos-core-1.15.0.manifest.json')), 'manifest copied too');
|
|
94
96
|
});
|
|
95
97
|
|
|
98
|
+
// The pin is a POSIX specifier on EVERY platform (task 1003839). `bongos init
|
|
99
|
+
// --vendor-core` and `bongos upgrade` both write `file:<vendorRel>` into an
|
|
100
|
+
// instance's package.json, and that package.json is COMMITTED — so a scaffold cut
|
|
101
|
+
// on a Windows laptop was shipping `file:vendor\\bongos-core-x.y.z.tgz` to a Linux
|
|
102
|
+
// provisioner running `npm ci`, which reads the backslash as part of the filename.
|
|
103
|
+
//
|
|
104
|
+
// Two halves, because neither alone can see the bug from CI. The STATIC half reads
|
|
105
|
+
// the sources and so fails on any platform; the BEHAVIOURAL half below it is silent on
|
|
106
|
+
// Linux (path.join already yields a forward slash there) and loud on Windows. The
|
|
107
|
+
// static half runs FIRST for exactly that reason: on Windows a regression trips the
|
|
108
|
+
// behavioural assertions and would abort the case before the platform-independent
|
|
109
|
+
// ones ever ran, hiding whether CI could have caught it.
|
|
110
|
+
t('the vendored core pin is POSIX on every platform, and no call site rebuilds it', () => {
|
|
111
|
+
// one place decides the separator, and that place spells it posix
|
|
112
|
+
const upgradeSrc = readFileSync(new URL('../scripts/gds/upgrade.js', import.meta.url), 'utf8');
|
|
113
|
+
assert.match(upgradeSrc, /function vendorRelFor\([^)]*\)\s*{\s*return path\.posix\.join\(/,
|
|
114
|
+
'vendorRelFor must build the pin with path.posix.join — it is an npm specifier, not a filesystem path');
|
|
115
|
+
for (const rel of ['scripts/gds/upgrade.js', 'scripts/gds/init.js']) {
|
|
116
|
+
const code = readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
117
|
+
assert.doesNotMatch(code, /path\.join\(\s*['"]vendor['"]\s*,/,
|
|
118
|
+
`${rel} must build the pin with vendorRelFor(), not path.join('vendor', …)`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// and the string it produces, end to end
|
|
122
|
+
assert.equal(u.vendorRelFor('1.17.0'), 'vendor/bongos-core-1.17.0.tgz');
|
|
123
|
+
assert.doesNotMatch(u.vendorRelFor('1.17.0'), /\\/, 'a package.json file: specifier carries no backslash');
|
|
124
|
+
// still a usable path: join() normalises a forward-slash relative on Windows too,
|
|
125
|
+
// so POSIX costs the fs side nothing
|
|
126
|
+
const dir = scratchConsumer();
|
|
127
|
+
mkdirSync(join(dir, 'vendor'), { recursive: true });
|
|
128
|
+
writeFileSync(join(dir, u.vendorRelFor('1.17.0')), 'TGZ');
|
|
129
|
+
assert.ok(existsSync(join(dir, u.vendorRelFor('1.17.0'))), 'the POSIX rel still resolves on disk');
|
|
130
|
+
// and the pin that reaches package.json is that string, verbatim
|
|
131
|
+
u.writePin(dir, u.vendorRelFor('1.17.0'));
|
|
132
|
+
assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies[u.CORE_PKG],
|
|
133
|
+
'file:vendor/bongos-core-1.17.0.tgz');
|
|
134
|
+
});
|
|
135
|
+
|
|
96
136
|
// ---- 3. step shims issue the right argv ------------------------------------
|
|
97
137
|
t('npmInstall / runMigrate / restartService: correct commands', () => {
|
|
98
138
|
const calls = [];
|