@bongos/core 1.19.79 → 1.19.81
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 +25 -15
- package/.claude/skills/builder-box/SKILL.md +11 -4
- package/modules/hall-ui/public/box-panel.js +30 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api.js +7 -3
- package/scripts/gds/box-sync.js +70 -3
- package/scripts/gds/cli-lib.js +28 -0
- package/src/module-api.js +5 -1
- package/tests/box_sync_scope_report.mjs +81 -0
- package/tests/cli_exit_no_abort.mjs +135 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.81",
|
|
6
|
+
"core_contract": "1.19.81",
|
|
7
|
+
"source_commit": "c19697303b3961520ebb175928cde6beddf00896",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-12T23:08:05.391Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 295,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1227,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1543,
|
|
20
|
+
"tree_sha256": "799b7337a46edabc3eb3adca6a29fb43c274e1ab27a9b130d09a986d4177cd9b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
{
|
|
38
38
|
"path": ".claude/skills/builder-box/SKILL.md",
|
|
39
39
|
"mode": "0000644",
|
|
40
|
-
"sha256": "
|
|
40
|
+
"sha256": "e131788fccfd87e882ef4e03798513cf7ff41047a2908fca8cf6314ff12e0524"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": ".claude/skills/builder-claim/SKILL.md",
|
|
@@ -2982,7 +2982,7 @@
|
|
|
2982
2982
|
{
|
|
2983
2983
|
"path": "modules/hall-ui/public/box-panel.js",
|
|
2984
2984
|
"mode": "0000644",
|
|
2985
|
-
"sha256": "
|
|
2985
|
+
"sha256": "3834d4aac8cb7ebc604973e145f396d0893b4cf62abc530f2027c491b31c625d"
|
|
2986
2986
|
},
|
|
2987
2987
|
{
|
|
2988
2988
|
"path": "modules/hall-ui/public/brand-apply.js",
|
|
@@ -4057,12 +4057,12 @@
|
|
|
4057
4057
|
{
|
|
4058
4058
|
"path": "package-lock.json",
|
|
4059
4059
|
"mode": "0000644",
|
|
4060
|
-
"sha256": "
|
|
4060
|
+
"sha256": "845771b9d06a05dcb4eb8b90a0002b26d1fc105c2d27d24f870f89640fb09769"
|
|
4061
4061
|
},
|
|
4062
4062
|
{
|
|
4063
4063
|
"path": "package.json",
|
|
4064
4064
|
"mode": "0000644",
|
|
4065
|
-
"sha256": "
|
|
4065
|
+
"sha256": "04febc37475484098b13f52462cda84d2a120c5ef64b7c2edf733e14ac6b3e1f"
|
|
4066
4066
|
},
|
|
4067
4067
|
{
|
|
4068
4068
|
"path": "public-docs/index.html",
|
|
@@ -4467,7 +4467,7 @@
|
|
|
4467
4467
|
{
|
|
4468
4468
|
"path": "scripts/gds/api.js",
|
|
4469
4469
|
"mode": "0000644",
|
|
4470
|
-
"sha256": "
|
|
4470
|
+
"sha256": "8f7332da44883801fb4674747c9e9a60e485353b36df6bebe1db8fc1ebd3558f"
|
|
4471
4471
|
},
|
|
4472
4472
|
{
|
|
4473
4473
|
"path": "scripts/gds/api.ps1",
|
|
@@ -4552,7 +4552,7 @@
|
|
|
4552
4552
|
{
|
|
4553
4553
|
"path": "scripts/gds/box-sync.js",
|
|
4554
4554
|
"mode": "0000644",
|
|
4555
|
-
"sha256": "
|
|
4555
|
+
"sha256": "981544bfd56d486c2017edd744c86089659771a74399b4780455821c45e3b3a9"
|
|
4556
4556
|
},
|
|
4557
4557
|
{
|
|
4558
4558
|
"path": "scripts/gds/box.js",
|
|
@@ -4617,7 +4617,7 @@
|
|
|
4617
4617
|
{
|
|
4618
4618
|
"path": "scripts/gds/cli-lib.js",
|
|
4619
4619
|
"mode": "0000644",
|
|
4620
|
-
"sha256": "
|
|
4620
|
+
"sha256": "e86444334085b00ff4256fcb6bf15e5f443dc75bdcbf8ca6eb87f0b41fcd3230"
|
|
4621
4621
|
},
|
|
4622
4622
|
{
|
|
4623
4623
|
"path": "scripts/gds/code.js",
|
|
@@ -5687,7 +5687,7 @@
|
|
|
5687
5687
|
{
|
|
5688
5688
|
"path": "src/module-api.js",
|
|
5689
5689
|
"mode": "0000644",
|
|
5690
|
-
"sha256": "
|
|
5690
|
+
"sha256": "3e4f5f5d1cb49b46416308dc7a4fa145b8260ca8dd0c63ed1a16ba499b622bc2"
|
|
5691
5691
|
},
|
|
5692
5692
|
{
|
|
5693
5693
|
"path": "src/module-loader/loader.js",
|
|
@@ -6029,6 +6029,11 @@
|
|
|
6029
6029
|
"mode": "0000644",
|
|
6030
6030
|
"sha256": "93bc891850e77350161214749954ddb257169ae323b7242f35becc4e6a9cabcf"
|
|
6031
6031
|
},
|
|
6032
|
+
{
|
|
6033
|
+
"path": "tests/box_sync_scope_report.mjs",
|
|
6034
|
+
"mode": "0000644",
|
|
6035
|
+
"sha256": "010525c500aa1d5133f85a1a2ea7c23e9a680b08493a6f3b78c69a5108501468"
|
|
6036
|
+
},
|
|
6032
6037
|
{
|
|
6033
6038
|
"path": "tests/box_task_scope.mjs",
|
|
6034
6039
|
"mode": "0000644",
|
|
@@ -6129,6 +6134,11 @@
|
|
|
6129
6134
|
"mode": "0000644",
|
|
6130
6135
|
"sha256": "b9258f690d5dc7c87bcbb12d510b3071a38669777ae9a92a99e683e76ea15e83"
|
|
6131
6136
|
},
|
|
6137
|
+
{
|
|
6138
|
+
"path": "tests/cli_exit_no_abort.mjs",
|
|
6139
|
+
"mode": "0000644",
|
|
6140
|
+
"sha256": "7bc4d54d29458c70cd545aecac1ca1f7b8cc13d7b1dacaa90a8c2184ef553304"
|
|
6141
|
+
},
|
|
6132
6142
|
{
|
|
6133
6143
|
"path": "tests/cli_lib_formatters.mjs",
|
|
6134
6144
|
"mode": "0000644",
|
|
@@ -7,7 +7,7 @@ You are showing a builder the status of **their own** dev box and helping them g
|
|
|
7
7
|
|
|
8
8
|
## Background
|
|
9
9
|
|
|
10
|
-
The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-and-park) and recreates on demand, so a builder's box is often `parked` between sessions — that's normal and cheap, not a problem. The read API:
|
|
10
|
+
The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-and-park) and recreates on demand, so a builder's box is often `parked` between sessions — that's normal and cheap, not a problem. **The idle sweep parks; it does not destroy** (task 1002726 — it *did* destroy droplet and snapshot for a long stretch, so a builder who remembers losing work that way is remembering something real that is now fixed). Full reclaim happens only after `BOX_DORMANT_DAYS` (14) parked, and **only after a warning was actually delivered** — an unwarned box is deferred, not reclaimed (task 1002727). The read API:
|
|
11
11
|
- `GET /api/gds/box/me` → the caller's box: `state`, `scope`, `ip`, `hostname`, cost, activity timestamps.
|
|
12
12
|
- `GET /api/gds/box/ssh-key` → the caller's registered SSH keys (fingerprints + labels).
|
|
13
13
|
- `GET /api/gds/box/terminal` → the caller's active box's browser-terminal `url` + basic-auth `credential` (the Chromebook/browser on-ramp, ADR 0038), once the box has published them.
|
|
@@ -16,17 +16,24 @@ The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-a
|
|
|
16
16
|
|
|
17
17
|
## How to use
|
|
18
18
|
|
|
19
|
-
1. **Fetch the box + keys** (own-scoped; uses the builder's session token):
|
|
19
|
+
1. **Fetch the box + keys + the resolved scope** (own-scoped; uses the builder's session token):
|
|
20
20
|
```bash
|
|
21
|
-
node -e 'const{apiCall}=require("./scripts/gds/cli-lib");(async()=>{for(const p of ["/api/gds/box/me","/api/gds/box/ssh-key"]){const r=await apiCall("GET",p);console.log(p,r.status,JSON.stringify(r.data));}})()'
|
|
21
|
+
node -e 'const{apiCall}=require("./scripts/gds/cli-lib");(async()=>{for(const p of ["/api/gds/box/me","/api/gds/box/ssh-key","/api/gds/box/scope"]){const r=await apiCall("GET",p);console.log(p,r.status,JSON.stringify(r.data));}})()'
|
|
22
22
|
```
|
|
23
23
|
(If the script returns "no GDS session", run `/builder-setup` first.)
|
|
24
24
|
|
|
25
|
+
**Always report the scope, not just the state** (task 1002731). `/box/scope` returns `{scope, modules, sparse_paths, task_scope}`, and a too-narrow box is otherwise a *silent* failure — the builder sees a working box that is quietly missing the code they need. Read it out:
|
|
26
|
+
- `scope: 'task'` → name the modules: *"your box holds dev-box, hall-ui — and nothing else."*
|
|
27
|
+
- `scope: 'base'` → **say plainly that the box has no `modules/` code at all.** With no active claim that is expected; claiming a task and running `/builder-sync` fixes it.
|
|
28
|
+
- `scope: 'full'` with `modules: []` → the task-1002729 fallback fired: they hold claims, but those claims' goals declare no `scope_modules`, so the box fell back to their rank. Usable, but task-scoping is not actually doing anything — setting the goal's wall would give a tighter box.
|
|
29
|
+
- `scope: 'starter'` → the fixed newcomer slice, not scoped to their claim.
|
|
30
|
+
|
|
25
31
|
2. **Relay it in plain language:**
|
|
26
32
|
- **state = `none`** → they have no box yet. On a Mac/Windows: run `/builder-connect` (registers their SSH key + installs the Desktop file). To actually create the box hands-off, `POST /api/gds/box/ensure` queues the auto-provision (no operator CLI needed). *(The old path — ask an operator to `box.js onboard <login>` — still works but is no longer required.)*
|
|
27
33
|
- **state = `active`** → box is up at `hostname` (`ip`). They can connect from Claude Desktop → Code → Environment → "Example Dev Box". For a browser/Chromebook (ADR 0038), the terminal comes from the web: `GET /api/gds/box/terminal` returns a `url` + a basic-auth `credential` (username `otb`, password = <redacted>; open the url, enter them, then type `claude` in the terminal. No SSH needed.
|
|
28
34
|
- **Live game preview — the sandbox (ADR 0044).** If `/box/terminal` returns a `url` (i.e. the box is on the named tunnel), the builder also has a private live preview of the game running on their own box, at the **same hostname with `term-` swapped for `sandbox-`** — e.g. terminal `https://term-<login>.example.com` → preview `https://sandbox-<login>.example.com`. It serves their working copy of the game (degraded — fully playable, no saved state), so they can review changes in a browser **without shipping to prod**. The loop: edit in the terminal → run **`/builder-stage`** (or `box-preview restart`) → refresh the preview tab. Staging via `/builder-stage` also records the review marker that `/builder-ship`'s sandbox-first gate checks (#927, ADR 0046) — on a dev box, game changes are staged + reviewed on the sandbox before they ship to the main game. `box-preview status` shows whether it's up + the URL; `box-preview logs` tails the server. (No preview on a quick-tunnel box — that path can't carry the game port; it needs the named tunnel.)
|
|
29
|
-
- **state = `parked`** → idle-suspended (cheap, expected). It wakes on demand: `POST /api/gds/box/ensure` queues the wake and it's back in ~1 min (no operator needed).
|
|
35
|
+
- **state = `parked`** → idle-suspended (cheap, expected). It wakes on demand: `POST /api/gds/box/ensure` queues the wake and it's back in ~1 min (no operator needed). Their data IS safe in the snapshot — say so plainly, and mean it: the idle path preserves the filesystem, and the only thing that deletes the snapshot warns them first with days of lead time.
|
|
36
|
+
- **state = `destroyed`** → the box is genuinely gone, and so is anything uncommitted on it. This is NOT the idle path — it means they closed it themselves, an operator deprovisioned it, or it sat parked past the dormancy window after being warned. `POST /box/ensure` gives them a **fresh** box, not their old one; don't imply otherwise.
|
|
30
37
|
- **state = `provisioning` / `waking`** → in flight; check back in a minute.
|
|
31
38
|
- **state = `error`** → flag it to an operator (don't try to fix infra from here).
|
|
32
39
|
- **SSH keys** → list the fingerprints + labels. If empty, they haven't connected yet → `/builder-connect`. They can remove a stale key with `DELETE /api/gds/box/ssh-key/:id` (own-scoped).
|
|
@@ -299,23 +299,43 @@
|
|
|
299
299
|
schedulePoll(5000);
|
|
300
300
|
return;
|
|
301
301
|
}
|
|
302
|
-
// No open intent and not active/provisioning/waking.
|
|
303
|
-
// •
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
302
|
+
// No open intent and not active/provisioning/waking. Three cases:
|
|
303
|
+
// • parked → asleep after the idle sweep, filesystem preserved (task 1002726).
|
|
304
|
+
// Reachable again as of that task; offer a wake, and say the files are safe.
|
|
305
|
+
// • destroyed → the builder HAD a box that is now gone — they closed it, an
|
|
306
|
+
// operator deprovisioned it, or it passed the dormancy window after being
|
|
307
|
+
// warned. Either way the tunnel + DNS are torn down, so opening the old
|
|
308
|
+
// terminal/sandbox URL fails with a raw browser "can't find the server" and
|
|
309
|
+
// no explanation (idea #295). Don't leave them staring at a bare button.
|
|
308
310
|
// • none → never had a box (first run). Keep the clean first-run button.
|
|
309
311
|
closeTermWindow();
|
|
310
312
|
stopPoll();
|
|
311
313
|
enableOpenBtn(null);
|
|
314
|
+
// task 1002728 — PARKED is a real, reachable state again since task 1002726
|
|
315
|
+
// made the idle sweep park instead of destroy. Before that it could never
|
|
316
|
+
// happen, so there was no branch for it and a parked box fell through to
|
|
317
|
+
// the first-run "Request dev box" screen — which reads as "your box is
|
|
318
|
+
// gone" at the exact moment the box and its files are safe.
|
|
319
|
+
if (state === 'parked') {
|
|
320
|
+
setRequestBtn(true, 'Wake my box');
|
|
321
|
+
setOut('<p class="setup__box-msg">Your dev box is asleep — wake it below.</p>'
|
|
322
|
+
+ '<p class="setup__box-close-hint">It was put to sleep after a couple of hours idle so it stops '
|
|
323
|
+
+ 'costing money. <strong>Your files are kept</strong>, including anything you had not committed — '
|
|
324
|
+
+ 'waking it brings the box back as you left it, in about a minute. A sleeping box has no terminal '
|
|
325
|
+
+ 'or live-preview URL, so an old link will fail to load until it is awake.</p>'
|
|
326
|
+
+ '<p class="setup__box-close-hint">If it stays asleep for a couple of weeks we clean it up to save '
|
|
327
|
+
+ 'cost, but you will get a warning first with several days to wake it.</p>');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
312
330
|
if (state === 'destroyed') {
|
|
313
331
|
setRequestBtn(true, 'Turn box back on');
|
|
314
332
|
setOut('<p class="setup__box-msg">Your dev box is stopped — turn it back on below.</p>'
|
|
315
|
-
+ '<p class="setup__box-close-hint">
|
|
316
|
-
+ '
|
|
317
|
-
+ 'link will fail to load. Turning it back on takes about a minute
|
|
318
|
-
+ '<strong>
|
|
333
|
+
+ '<p class="setup__box-close-hint">This happens when you close a box yourself, or after it has been '
|
|
334
|
+
+ 'asleep long enough to be cleaned up (you are warned first). A stopped box has no terminal or '
|
|
335
|
+
+ 'live-preview URL, so an old link will fail to load. Turning it back on takes about a minute, and '
|
|
336
|
+
+ 'gives you a <strong>fresh</strong> box — <strong>anything not committed or shipped on the old one '
|
|
337
|
+
+ 'is gone</strong>. (Going idle no longer does this: an idle box is put to sleep with your files '
|
|
338
|
+
+ 'intact.)</p>'
|
|
319
339
|
+ '<p class="setup__box-close-hint">The box you turn on has a <strong>new identity</strong>. If Claude '
|
|
320
340
|
+ 'Desktop says <strong>“Host denied”</strong> or asks for a password when reconnecting, fully quit '
|
|
321
341
|
+ 'and reopen Desktop (it re-pins your box automatically) or press <strong>Repair</strong> — do not keep turning '
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.81",
|
|
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.81",
|
|
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.81",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/api.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// on a 2xx, 1 on any non-2xx or transport error, 2 on bad invocation.
|
|
19
19
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
|
-
const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit } = require('./cli-lib');
|
|
21
|
+
const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit } = require('./cli-lib');
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
(async () => {
|
|
@@ -62,7 +62,7 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
|
|
|
62
62
|
res = await api.request(method, relPath, body != null ? { body } : {});
|
|
63
63
|
} catch (e) {
|
|
64
64
|
console.error(`api: request failed: ${e.message}`);
|
|
65
|
-
process.exit(
|
|
65
|
+
return cliExit(1); // task 1002734: never process.exit() after a request
|
|
66
66
|
}
|
|
67
67
|
if (!quiet) console.error(`${method} ${urlPath} → ${res.status}`);
|
|
68
68
|
// task 1501: on a dev box the baked session is box-scoped (ADR 0053) and every
|
|
@@ -79,5 +79,9 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
|
|
|
79
79
|
if (res.data !== undefined && res.data !== null) {
|
|
80
80
|
console.log(JSON.stringify(res.data, null, 2));
|
|
81
81
|
}
|
|
82
|
-
process.exit(
|
|
82
|
+
// task 1002734: process.exit() here aborted node 5/5 on Windows —
|
|
83
|
+
// "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c"
|
|
84
|
+
// — AFTER the JSON had already printed, so every caller that checks the exit code
|
|
85
|
+
// read a successful command as a failure. Set the code and let the loop drain.
|
|
86
|
+
cliExit(res.ok ? 0 : 1);
|
|
83
87
|
})();
|
package/scripts/gds/box-sync.js
CHANGED
|
@@ -57,10 +57,77 @@ function main(argv = process.argv.slice(2)) {
|
|
|
57
57
|
console.error(`sync: could not run the fetch (${r.error.message})`);
|
|
58
58
|
process.exit(1);
|
|
59
59
|
}
|
|
60
|
-
if (r.status
|
|
61
|
-
|
|
60
|
+
if (r.status !== 0) process.exit(r.status == null ? 1 : r.status);
|
|
61
|
+
console.log('sync: done — your box now holds the code for your active claim.');
|
|
62
|
+
// Then say WHAT it holds, so a base-only box is visible instead of silent.
|
|
63
|
+
reportScope().then(() => process.exit(0), () => process.exit(0));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// task 1002731 — say what scope the box ACTUALLY got
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
// The failure this exists for is a SILENT one: a box comes up holding only the
|
|
71
|
+
// base slice (no modules/ at all) and nothing anywhere says so. Live, that was
|
|
72
|
+
// 247 of 248 source grants — including an Archon with 7 active claims — and the
|
|
73
|
+
// only way to see it was reading box_events on the control plane. GET /box/scope
|
|
74
|
+
// already returns the resolved decision; nothing ever showed it to a human.
|
|
75
|
+
//
|
|
76
|
+
// PURE so it is unit-testable without a box or a network: takes the /box/scope
|
|
77
|
+
// body, returns the lines to print. null/garbage in → one honest line rather
|
|
78
|
+
// than a crash, because this is diagnostic output on an already-degraded path
|
|
79
|
+
// and must never be the thing that fails.
|
|
80
|
+
function formatScopeReport(scope) {
|
|
81
|
+
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
|
|
82
|
+
return ['scope: could not read your box scope (GET /box/scope did not answer).'];
|
|
83
|
+
}
|
|
84
|
+
const modules = Array.isArray(scope.modules) ? scope.modules : null;
|
|
85
|
+
const paths = Array.isArray(scope.sparse_paths) ? scope.sparse_paths : [];
|
|
86
|
+
const out = [];
|
|
87
|
+
|
|
88
|
+
if (scope.scope === 'task' && modules && modules.length) {
|
|
89
|
+
out.push(`scope: task — your box holds ${modules.length} module(s): ${modules.join(', ')}`);
|
|
90
|
+
} else if (scope.scope === 'base') {
|
|
91
|
+
out.push('scope: BASE ONLY — your box holds no modules/ code at all.');
|
|
92
|
+
out.push(scope.task_scope
|
|
93
|
+
? ' Why: you hold no active claim, so there is no work to scope the box to.'
|
|
94
|
+
: ' Why: this instance resolved no claim scope.');
|
|
95
|
+
out.push(' Fix: claim a task (/builder-claim N), then re-run this sync.');
|
|
96
|
+
} else if (scope.scope === 'full') {
|
|
97
|
+
out.push('scope: full — your box holds the whole repo (your rank allows it).');
|
|
98
|
+
if (modules && modules.length === 0) {
|
|
99
|
+
// The task-1002729 fallback fired: claims held, but their goals declare no
|
|
100
|
+
// wall. Worth naming — the box is usable, but task-scoping is doing nothing.
|
|
101
|
+
out.push(' Note: your claimed work declares no module scope, so this fell back to');
|
|
102
|
+
out.push(" your rank rather than scoping to the task. Setting the goal's");
|
|
103
|
+
out.push(' scope_modules would give you a tighter box.');
|
|
104
|
+
}
|
|
105
|
+
} else if (scope.scope === 'starter') {
|
|
106
|
+
out.push('scope: starter — a fixed slice for newcomers, not scoped to your claim.');
|
|
107
|
+
} else {
|
|
108
|
+
out.push(`scope: ${String(scope.scope || 'unknown')}`);
|
|
109
|
+
}
|
|
110
|
+
if (paths.length) out.push(` paths: ${paths.length} sparse entr${paths.length === 1 ? 'y' : 'ies'}`);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Read the resolved scope and print it. Never throws and never changes the exit
|
|
115
|
+
// status — a diagnostic that could fail the sync it reports on would be worse
|
|
116
|
+
// than no diagnostic.
|
|
117
|
+
async function reportScope() {
|
|
118
|
+
try {
|
|
119
|
+
const { apiCall } = require('./cli-lib');
|
|
120
|
+
const r = await apiCall('GET', '/api/gds/box/scope');
|
|
121
|
+
if (!r || r.status !== 200) {
|
|
122
|
+
console.log('scope: could not read your box scope right now (that does not affect the sync above).');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const line of formatScopeReport(r.data)) console.log(line);
|
|
126
|
+
} catch (_) {
|
|
127
|
+
console.log('scope: could not read your box scope right now (that does not affect the sync above).');
|
|
128
|
+
}
|
|
62
129
|
}
|
|
63
130
|
|
|
64
131
|
if (require.main === module) main();
|
|
65
132
|
|
|
66
|
-
module.exports = { resolveFetchScript, FETCH_SCRIPT };
|
|
133
|
+
module.exports = { resolveFetchScript, FETCH_SCRIPT, formatScopeReport };
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -115,6 +115,20 @@ function hallBase(base = API_BASE) {
|
|
|
115
115
|
// buys these one-shot processes nothing, so route requests through a dispatcher
|
|
116
116
|
// with keep-alive effectively disabled (mirrors cf-tunnel.js's dispatcher
|
|
117
117
|
// pattern). Degrades to the default dispatcher if undici can't be required.
|
|
118
|
+
//
|
|
119
|
+
// task 1002734 — THIS ALONE IS NOT ENOUGH, and the measurements say so: with the
|
|
120
|
+
// dispatcher wired in and undici resolving fine, `node scripts/gds/api.js GET
|
|
121
|
+
// /tasks/<id>` still aborted 5/5 on Windows 11 / Node 24. The dispatcher removes
|
|
122
|
+
// one contributing handle; the actual trigger is calling process.exit() at all
|
|
123
|
+
// while the request machinery is still tearing down. A minimal synthetic
|
|
124
|
+
// fetch-then-exit does NOT reproduce, so it takes the real stack (the dynamic ESM
|
|
125
|
+
// client import + the Agent + any child spawn / consumed stdin) to line up — which
|
|
126
|
+
// is exactly why hunting the single guilty handle is a dead end.
|
|
127
|
+
//
|
|
128
|
+
// The rule for every CLI here: after a request, DO NOT call process.exit().
|
|
129
|
+
// Set process.exitCode and return, and let the loop drain — see cliExit below.
|
|
130
|
+
// Verified: the pre-push gate went 8/8 aborting to 0/8 on that change alone
|
|
131
|
+
// (task 1002725), and api.js 5/5 to 0/5 (this task).
|
|
118
132
|
let _noKeepAliveDispatcher;
|
|
119
133
|
let _noKeepAliveTried = false;
|
|
120
134
|
function noKeepAliveDispatcher() {
|
|
@@ -127,6 +141,19 @@ function noKeepAliveDispatcher() {
|
|
|
127
141
|
return _noKeepAliveDispatcher;
|
|
128
142
|
}
|
|
129
143
|
|
|
144
|
+
// cliExit(code) — the post-request replacement for process.exit(code). Records the
|
|
145
|
+
// exit status and lets node exit naturally once the loop drains, which is what
|
|
146
|
+
// keeps the UV_HANDLE_CLOSING abort above from firing. Callers must `return` after
|
|
147
|
+
// calling it: unlike process.exit() it does NOT stop execution.
|
|
148
|
+
//
|
|
149
|
+
// Gotcha (learned in task 1002725): a hard exit MASKS leaked timers/handles. Once a
|
|
150
|
+
// CLI exits naturally, anything still armed — an uncleared setTimeout, an open
|
|
151
|
+
// interval — keeps the process alive. Clear them, or the CLI hangs instead of
|
|
152
|
+
// crashing.
|
|
153
|
+
function cliExit(code = 0) {
|
|
154
|
+
process.exitCode = code;
|
|
155
|
+
}
|
|
156
|
+
|
|
130
157
|
async function readSessionFile(p) {
|
|
131
158
|
try {
|
|
132
159
|
const raw = await fsp.readFile(p, 'utf8');
|
|
@@ -760,6 +787,7 @@ module.exports = {
|
|
|
760
787
|
resolveQuiet,
|
|
761
788
|
readSessionToken,
|
|
762
789
|
apiCall,
|
|
790
|
+
cliExit,
|
|
763
791
|
cliClient,
|
|
764
792
|
requireSession,
|
|
765
793
|
printUnauthGuidanceAndExit,
|
package/src/module-api.js
CHANGED
|
@@ -585,8 +585,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
585
585
|
// landed since 1.19.77 with no explicit bump. run 31648127800. (task 1002620)
|
|
586
586
|
// 1.19.79 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
587
587
|
// landed since 1.19.78 with no explicit bump. run 31648954641. (task 1002620)
|
|
588
|
+
// 1.19.80 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
589
|
+
// landed since 1.19.79 with no explicit bump. run 31649366675. (task 1002620)
|
|
590
|
+
// 1.19.81 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
591
|
+
// landed since 1.19.80 with no explicit bump. run 31649705242. (task 1002620)
|
|
588
592
|
// ---------------------------------------------------------------------------
|
|
589
|
-
const CORE_VERSION = '1.19.
|
|
593
|
+
const CORE_VERSION = '1.19.81'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
590
594
|
|
|
591
595
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
592
596
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// tests/box_sync_scope_report.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1002731 — the scope read-out that makes a too-narrow box VISIBLE.
|
|
4
|
+
//
|
|
5
|
+
// The failure being guarded is a silent one: a box comes up holding only the
|
|
6
|
+
// base slice (no modules/ at all) and nothing tells the builder. Live, that was
|
|
7
|
+
// 247 of 248 source grants on the cloudbongos fleet — including an Archon
|
|
8
|
+
// holding 7 active claims — and the only way to see it was reading box_events
|
|
9
|
+
// on the control plane.
|
|
10
|
+
//
|
|
11
|
+
// formatScopeReport is PURE (takes the GET /box/scope body, returns lines), so
|
|
12
|
+
// this needs no box, no network and no DB.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/box_sync_scope_report.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { formatScopeReport } = require('../scripts/gds/box-sync.js');
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
let failed = 0;
|
|
24
|
+
function t(name, fn) {
|
|
25
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
26
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
27
|
+
}
|
|
28
|
+
const joined = (scope) => formatScopeReport(scope).join('\n');
|
|
29
|
+
|
|
30
|
+
t('base-only is stated in plain words, with a why and a fix', () => {
|
|
31
|
+
const out = joined({ scope: 'base', mode: 'sparse', modules: [], sparse_paths: ['docs', 'scripts'], task_scope: true });
|
|
32
|
+
assert.match(out, /BASE ONLY/, 'the degraded state is named, not buried');
|
|
33
|
+
assert.match(out, /no modules\/ code at all/, 'says what is actually missing');
|
|
34
|
+
assert.match(out, /Why:/, 'explains the cause');
|
|
35
|
+
assert.match(out, /builder-claim/, 'gives the builder the actual next step');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
t('task scope names the modules the box holds', () => {
|
|
39
|
+
const out = joined({ scope: 'task', mode: 'sparse', modules: ['dev-box', 'hall-ui'], sparse_paths: ['a', 'b', 'c'], task_scope: true });
|
|
40
|
+
assert.match(out, /scope: task/);
|
|
41
|
+
assert.match(out, /dev-box, hall-ui/, 'names them rather than just counting');
|
|
42
|
+
assert.match(out, /3 sparse entries/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
t('full + EMPTY modules surfaces the 1002729 fallback rather than looking healthy', () => {
|
|
46
|
+
// The exact live shape: claims held, but their goals declare no scope_modules.
|
|
47
|
+
// The box works, so nothing complains — but task-scoping is not doing anything,
|
|
48
|
+
// and that is worth telling someone.
|
|
49
|
+
const out = joined({ scope: 'full', mode: 'full', modules: [], sparse_paths: null, task_scope: true });
|
|
50
|
+
assert.match(out, /scope: full/);
|
|
51
|
+
assert.match(out, /declares no module scope/, 'names why it fell back');
|
|
52
|
+
assert.match(out, /scope_modules/, 'points at the thing to fix');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
t('full with real modules does NOT nag about the fallback', () => {
|
|
56
|
+
const out = joined({ scope: 'full', mode: 'full', modules: ['kernel'], sparse_paths: null, task_scope: false });
|
|
57
|
+
assert.match(out, /scope: full/);
|
|
58
|
+
assert.ok(!/declares no module scope/.test(out), 'no false alarm when nothing is wrong');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
t('starter is distinguished from a claim-scoped box', () => {
|
|
62
|
+
const out = joined({ scope: 'starter', mode: 'sparse', modules: null, sparse_paths: ['x'], task_scope: false });
|
|
63
|
+
assert.match(out, /starter/);
|
|
64
|
+
assert.match(out, /not scoped to your claim/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
t('a missing or malformed body degrades to one honest line, never a throw', () => {
|
|
68
|
+
// This runs on an already-degraded path; it must not be the thing that fails.
|
|
69
|
+
for (const bad of [null, undefined, 'nope', 42, []]) {
|
|
70
|
+
const lines = formatScopeReport(bad);
|
|
71
|
+
assert.ok(Array.isArray(lines) && lines.length >= 1, `handled ${JSON.stringify(bad)}`);
|
|
72
|
+
}
|
|
73
|
+
assert.match(joined(null), /could not read/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
t('an unknown scope value is reported verbatim rather than swallowed', () => {
|
|
77
|
+
assert.match(joined({ scope: 'something-new', sparse_paths: [] }), /something-new/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
console.log(`\nbox_sync_scope_report.mjs: ${passed} passed, ${failed} failed`);
|
|
81
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tests/cli_exit_no_abort.mjs — the GDS CLIs must not abort natively on exit
|
|
2
|
+
// (task 1002734; the pre-push twin of this is tests/pre_push_hook.mjs, task 1002725).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG. On Windows + Node 24, calling process.exit() after the CLI has made a
|
|
5
|
+
// request aborts the process before it can exit cleanly:
|
|
6
|
+
//
|
|
7
|
+
// Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76
|
|
8
|
+
//
|
|
9
|
+
// `node scripts/gds/api.js GET /tasks/<id>` reproduced this 5/5: the JSON printed
|
|
10
|
+
// correctly and THEN the process died with exit 127, so every caller that checks an
|
|
11
|
+
// exit code — CI, shell scripts, the ship pipeline — read a successful command as a
|
|
12
|
+
// failure. Setting process.exitCode and letting the loop drain took it to 0/6.
|
|
13
|
+
//
|
|
14
|
+
// A no-keep-alive undici dispatcher was already wired into cli-lib for this exact
|
|
15
|
+
// assertion and was NOT sufficient on its own; only dropping the hard exit fixed it.
|
|
16
|
+
//
|
|
17
|
+
// NETWORK-FREE, but not server-free. The failure cases point the CLI at a dead
|
|
18
|
+
// localhost port; the success case runs a throwaway stub server in its own process.
|
|
19
|
+
// Both matter: the crash is in the TEARDOWN, and a 200 tears down more machinery than
|
|
20
|
+
// a refused connection — the wild repro was a successful call.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/cli_exit_no_abort.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const API = path.join(ROOT, 'scripts', 'gds', 'api.js');
|
|
33
|
+
const CLI_LIB = path.join(ROOT, 'scripts', 'gds', 'cli-lib.js');
|
|
34
|
+
const DEAD_BASE = 'http://127.0.0.1:9'; // discard port — connection refused, instantly
|
|
35
|
+
|
|
36
|
+
// The native abort prints this and nothing else can be relied on; it is NOT a
|
|
37
|
+
// JS-level error, so it never appears as a caught exception.
|
|
38
|
+
const ABORT_RE = /Assertion failed|UV_HANDLE_CLOSING/i;
|
|
39
|
+
|
|
40
|
+
function runApi(args, env = {}) {
|
|
41
|
+
return spawnSync(process.execPath, [API, ...args], {
|
|
42
|
+
cwd: ROOT,
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
env: { ...process.env, GDS_API_BASE: DEAD_BASE, ...env },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('api.js does not abort natively when a request fails', () => {
|
|
49
|
+
const r = runApi(['GET', '/api/gds/me', '--quiet']);
|
|
50
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE, `native abort on the request-failure path:\n${r.stderr}`);
|
|
51
|
+
assert.equal(r.status, 1, `a failed request must exit 1, not ${r.status} (127 = the abort)`);
|
|
52
|
+
assert.match(r.stderr, /request failed/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('api.js reports a usage error without aborting (pre-request path)', () => {
|
|
56
|
+
const r = runApi(['GET']); // missing path
|
|
57
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE);
|
|
58
|
+
assert.equal(r.status, 2, 'bad invocation is exit 2');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('api.js exit code survives repeated runs (the abort was intermittent-looking)', () => {
|
|
62
|
+
// The synthetic minimal case (bare fetch + process.exit) does NOT reproduce, so a
|
|
63
|
+
// single green run proves little — the real stack has to line up. Run it enough
|
|
64
|
+
// times that a reintroduced hard exit shows up rather than slipping through.
|
|
65
|
+
for (let i = 0; i < 5; i++) {
|
|
66
|
+
const r = runApi(['GET', '/api/gds/me', '--quiet']);
|
|
67
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively`);
|
|
68
|
+
assert.equal(r.status, 1, `run ${i + 1} exited ${r.status}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The SUCCESS teardown is the path that actually aborted in the wild: api.js printed
|
|
73
|
+
// its JSON and then died at exit 127. A failed request tears down less machinery, so
|
|
74
|
+
// the failure cases above cannot cover this. Serve a real 200 — still no external
|
|
75
|
+
// network — and assert the clean exit 0.
|
|
76
|
+
//
|
|
77
|
+
// The stub MUST run in its own process. An in-process http.createServer here would
|
|
78
|
+
// deadlock: spawnSync blocks this process's event loop, so the server could never
|
|
79
|
+
// accept the child's connection and the test would hang forever.
|
|
80
|
+
const STUB_SRC = `
|
|
81
|
+
const http = require('node:http');
|
|
82
|
+
const s = http.createServer((q, r) => {
|
|
83
|
+
r.writeHead(200, { 'Content-Type': 'application/json' });
|
|
84
|
+
r.end(JSON.stringify({ ok: true, builder: { id: 1, rank: 'archon' } }));
|
|
85
|
+
});
|
|
86
|
+
s.listen(0, '127.0.0.1', () => console.log('PORT=' + s.address().port));
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
function startStub() {
|
|
90
|
+
const proc = spawn(process.execPath, ['-e', STUB_SRC], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
let buf = '';
|
|
93
|
+
const timer = setTimeout(() => { proc.kill(); reject(new Error('stub server did not report a port')); }, 10_000);
|
|
94
|
+
proc.stdout.on('data', (d) => {
|
|
95
|
+
buf += d.toString();
|
|
96
|
+
const m = buf.match(/PORT=(\d+)/);
|
|
97
|
+
if (m) { clearTimeout(timer); resolve({ proc, port: Number(m[1]) }); }
|
|
98
|
+
});
|
|
99
|
+
proc.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('api.js exits 0 after a SUCCESSFUL request — the path that aborted in the wild', async () => {
|
|
104
|
+
const { proc, port } = await startStub();
|
|
105
|
+
try {
|
|
106
|
+
for (let i = 0; i < 3; i++) {
|
|
107
|
+
const r = runApi(['GET', '/api/gds/me', '--quiet'], { GDS_API_BASE: `http://127.0.0.1:${port}` });
|
|
108
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively after a 200:\n${r.stderr}`);
|
|
109
|
+
assert.equal(r.status, 0, `a successful request must exit 0, got ${r.status} (127 = the abort); stderr: ${r.stderr}`);
|
|
110
|
+
assert.match(r.stdout, /"ok"/, 'the JSON body must still reach stdout');
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
proc.kill();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('cli-lib exports cliExit — the documented replacement for a post-request exit', async () => {
|
|
118
|
+
const mod = await import(`file://${CLI_LIB.split(path.sep).join('/')}`);
|
|
119
|
+
const cliExit = mod.default?.cliExit ?? mod.cliExit;
|
|
120
|
+
assert.equal(typeof cliExit, 'function', 'cliExit must be exported for the CLI sweep to adopt');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('api.js routes its post-request exits through cliExit, not process.exit', () => {
|
|
124
|
+
const src = fs.readFileSync(API, 'utf8');
|
|
125
|
+
// The two surviving process.exit() calls are the pre-request guards (usage + body
|
|
126
|
+
// parse), which run before any network work and are safe. Everything after the
|
|
127
|
+
// request must use cliExit.
|
|
128
|
+
const afterRequest = src.slice(src.indexOf('let res;'));
|
|
129
|
+
const stripped = afterRequest.replace(/\/\/[^\n]*/g, '');
|
|
130
|
+
assert.ok(
|
|
131
|
+
!/process\.exit\s*\(/.test(stripped),
|
|
132
|
+
'no process.exit() may remain after the request is issued — that is what aborts node on Windows'
|
|
133
|
+
);
|
|
134
|
+
assert.match(afterRequest, /cliExit\(/, 'the post-request paths must set the exit code via cliExit');
|
|
135
|
+
});
|