@bongos/core 1.19.78 → 1.19.80
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 +22 -17
- package/.claude/skills/builder-box/SKILL.md +3 -2
- package/modules/dev-box/boxes.js +40 -0
- 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.js +61 -13
- package/scripts/gds/cli-lib.js +28 -0
- package/src/module-api.js +5 -1
- package/tests/boxes.mjs +92 -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.80",
|
|
6
|
+
"core_contract": "1.19.80",
|
|
7
|
+
"source_commit": "e9830526a46cd238c6ea856406a1bcde6fcb6e4f",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-12T23:02:51.567Z",
|
|
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": 1226,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1542,
|
|
20
|
+
"tree_sha256": "8efe860e490a9f9bca1f3739c652e205e29d50643e86361a1eb631f7474a1ba9",
|
|
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": "d8bfc9298bbb136742147e83d2500b9cb0961fec35ca15dc3cb773c9ecdf8b4b"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": ".claude/skills/builder-claim/SKILL.md",
|
|
@@ -2597,7 +2597,7 @@
|
|
|
2597
2597
|
{
|
|
2598
2598
|
"path": "modules/dev-box/boxes.js",
|
|
2599
2599
|
"mode": "0000644",
|
|
2600
|
-
"sha256": "
|
|
2600
|
+
"sha256": "a56b75218b3ac3fed0bb495b9c7f41b2794dafa5f3e6ec28cbb21a97e9b25b7b"
|
|
2601
2601
|
},
|
|
2602
2602
|
{
|
|
2603
2603
|
"path": "modules/dev-box/db.js",
|
|
@@ -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": "b7e3e581321a968a2978affc8766e5bc9ec8f412f0c3e7a94c47916138724de9"
|
|
4061
4061
|
},
|
|
4062
4062
|
{
|
|
4063
4063
|
"path": "package.json",
|
|
4064
4064
|
"mode": "0000644",
|
|
4065
|
-
"sha256": "
|
|
4065
|
+
"sha256": "283e5a12a702be6937db0eb28a69775e3cb6b760ae217b3824d65ba7feb6743b"
|
|
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",
|
|
@@ -4557,7 +4557,7 @@
|
|
|
4557
4557
|
{
|
|
4558
4558
|
"path": "scripts/gds/box.js",
|
|
4559
4559
|
"mode": "0000644",
|
|
4560
|
-
"sha256": "
|
|
4560
|
+
"sha256": "117b0ed6730e35559356403b50240d0ef5b6ab46752a8cf76de4e0a8ad244d42"
|
|
4561
4561
|
},
|
|
4562
4562
|
{
|
|
4563
4563
|
"path": "scripts/gds/build-bongos.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": "e85465ed2f13494396cf2d74bad0c8a2b6e52d42d48c493a3d0dcf4694f6072e"
|
|
5691
5691
|
},
|
|
5692
5692
|
{
|
|
5693
5693
|
"path": "src/module-loader/loader.js",
|
|
@@ -6042,7 +6042,7 @@
|
|
|
6042
6042
|
{
|
|
6043
6043
|
"path": "tests/boxes.mjs",
|
|
6044
6044
|
"mode": "0000644",
|
|
6045
|
-
"sha256": "
|
|
6045
|
+
"sha256": "5a8d2ca5709f2c4ae5089b53883a5a892beeb6920bacbddbd352b106c4ec0e34"
|
|
6046
6046
|
},
|
|
6047
6047
|
{
|
|
6048
6048
|
"path": "tests/branding.mjs",
|
|
@@ -6129,6 +6129,11 @@
|
|
|
6129
6129
|
"mode": "0000644",
|
|
6130
6130
|
"sha256": "b9258f690d5dc7c87bcbb12d510b3071a38669777ae9a92a99e683e76ea15e83"
|
|
6131
6131
|
},
|
|
6132
|
+
{
|
|
6133
|
+
"path": "tests/cli_exit_no_abort.mjs",
|
|
6134
|
+
"mode": "0000644",
|
|
6135
|
+
"sha256": "7bc4d54d29458c70cd545aecac1ca1f7b8cc13d7b1dacaa90a8c2184ef553304"
|
|
6136
|
+
},
|
|
6132
6137
|
{
|
|
6133
6138
|
"path": "tests/cli_lib_formatters.mjs",
|
|
6134
6139
|
"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.
|
|
@@ -26,7 +26,8 @@ The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-a
|
|
|
26
26
|
- **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
27
|
- **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
28
|
- **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).
|
|
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). 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.
|
|
30
|
+
- **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
31
|
- **state = `provisioning` / `waking`** → in flight; check back in a minute.
|
|
31
32
|
- **state = `error`** → flag it to an operator (don't try to fix infra from here).
|
|
32
33
|
- **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).
|
package/modules/dev-box/boxes.js
CHANGED
|
@@ -182,6 +182,10 @@ function selectIdleBoxes(rows, { idleMinutes, nowMs } = {}) {
|
|
|
182
182
|
|
|
183
183
|
// Dormant = parked boxes that have been parked longer than dormantDays. These
|
|
184
184
|
// get fully de-provisioned (snapshot deleted too) to reclaim the last cents.
|
|
185
|
+
// Since task 1002726 this is the ONLY automated path that destroys a snapshot —
|
|
186
|
+
// the idle sweep parks and preserves one — so it is the last thing standing
|
|
187
|
+
// between a builder and losing uncommitted work. Task 1002727 gates it on a
|
|
188
|
+
// delivered warning.
|
|
185
189
|
function selectDormantBoxes(rows, { dormantDays, nowMs } = {}) {
|
|
186
190
|
if (!Number.isFinite(dormantDays) || dormantDays <= 0) return [];
|
|
187
191
|
if (!Number.isFinite(nowMs)) return [];
|
|
@@ -193,6 +197,40 @@ function selectDormantBoxes(rows, { dormantDays, nowMs } = {}) {
|
|
|
193
197
|
});
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
// Task 1002727 — the WARNING window: parked long enough to be heading for
|
|
201
|
+
// reclaim, but not yet reclaimable. A box here gets one heads-up so its builder
|
|
202
|
+
// can wake it (or accept the loss) before the snapshot goes. Boxes already past
|
|
203
|
+
// dormantDays are excluded — they belong to selectDormantBoxes, which refuses to
|
|
204
|
+
// reclaim an unwarned box. Pure; the caller injects nowMs.
|
|
205
|
+
function selectDormantWarnBoxes(rows, { dormantDays, warnLeadDays, nowMs } = {}) {
|
|
206
|
+
if (!Number.isFinite(dormantDays) || dormantDays <= 0) return [];
|
|
207
|
+
if (!Number.isFinite(warnLeadDays) || warnLeadDays <= 0) return [];
|
|
208
|
+
if (!Number.isFinite(nowMs)) return [];
|
|
209
|
+
const warnAfter = nowMs - Math.max(dormantDays - warnLeadDays, 0) * 86_400_000;
|
|
210
|
+
const reclaimAfter = nowMs - dormantDays * 86_400_000;
|
|
211
|
+
return (rows || []).filter((r) => {
|
|
212
|
+
if (r.state !== 'parked') return false;
|
|
213
|
+
const p = toMs(r.parked_at);
|
|
214
|
+
return p != null && p <= warnAfter && p > reclaimAfter;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Task 1002727 — was this box already warned during its CURRENT parked interval?
|
|
219
|
+
// box_events is the state store, so this needs no schema change: a
|
|
220
|
+
// 'dormant_warning' row written at or after parked_at is the receipt. Keying on
|
|
221
|
+
// parked_at (rather than just the newest event) means a wake→park cycle re-arms
|
|
222
|
+
// the warning instead of reusing a stale one from a previous park.
|
|
223
|
+
async function hasDormantWarning(db, { boxId, parkedAt } = {}) {
|
|
224
|
+
if (!boxId || !parkedAt) return false;
|
|
225
|
+
const { rows } = await db.query(
|
|
226
|
+
`SELECT 1 FROM box_events
|
|
227
|
+
WHERE box_id = $1 AND event = 'dormant_warning' AND created_at >= $2
|
|
228
|
+
LIMIT 1`,
|
|
229
|
+
[boxId, parkedAt]
|
|
230
|
+
);
|
|
231
|
+
return rows.length > 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
196
234
|
// ---------------------------------------------------------------------------
|
|
197
235
|
// PURE: state-transition legality
|
|
198
236
|
// ---------------------------------------------------------------------------
|
|
@@ -834,6 +872,8 @@ module.exports = {
|
|
|
834
872
|
effectiveActivityMs,
|
|
835
873
|
selectIdleBoxes,
|
|
836
874
|
selectDormantBoxes,
|
|
875
|
+
selectDormantWarnBoxes,
|
|
876
|
+
hasDormantWarning,
|
|
837
877
|
// pure — transitions
|
|
838
878
|
TRANSITIONS,
|
|
839
879
|
canTransition,
|
|
@@ -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.80",
|
|
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.80",
|
|
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.80",
|
|
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.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// wake <builder> recreate the droplet from its snapshot
|
|
16
16
|
// deprovision <builder> destroy droplet AND snapshot (full reclaim)
|
|
17
17
|
// sweep-idle PARK every active box idle > BOX_IDLE_MINUTES (snapshot kept)
|
|
18
|
-
// sweep-dormant deprovision every box parked > BOX_DORMANT_DAYS
|
|
18
|
+
// sweep-dormant warn, then deprovision every box parked > BOX_DORMANT_DAYS
|
|
19
|
+
// (the ONLY automated path that destroys a snapshot)
|
|
19
20
|
// status print the box roster + estimated cost
|
|
20
21
|
//
|
|
21
22
|
// <builder> is a GitHub login or a numeric builder id.
|
|
@@ -102,6 +103,8 @@ const CONFIG = {
|
|
|
102
103
|
baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
|
|
103
104
|
idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
|
|
104
105
|
dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
|
|
106
|
+
// task 1002727 — how many days BEFORE the reclaim a builder gets warned.
|
|
107
|
+
dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
|
|
105
108
|
sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
106
109
|
dnsHook: process.env.BOX_DNS_HOOK || null,
|
|
107
110
|
tag: process.env.BOX_TAG || 'otb-builder-box',
|
|
@@ -1051,27 +1054,71 @@ async function cmdSweepIdle(pool, boxes, { apply }) {
|
|
|
1051
1054
|
}
|
|
1052
1055
|
|
|
1053
1056
|
// ---- sweep-dormant ---------------------------------------------------------
|
|
1057
|
+
// Task 1002727 — since the idle sweep parks rather than destroys (task 1002726),
|
|
1058
|
+
// this is the ONLY automated path that deletes a snapshot, i.e. the only one that
|
|
1059
|
+
// can take a builder's uncommitted work. So it warns before it takes: a box
|
|
1060
|
+
// entering the warning window gets one heads-up, and a box past the threshold is
|
|
1061
|
+
// reclaimed ONLY if that warning was actually delivered during its current parked
|
|
1062
|
+
// interval. If it never was — runner downtime, a chat outage, a box that sailed
|
|
1063
|
+
// past the window while the sweep was off — the reclaim is DEFERRED and the
|
|
1064
|
+
// warning is sent instead. That way "no automated path silently discards work"
|
|
1065
|
+
// holds even when the machinery itself misbehaves.
|
|
1054
1066
|
|
|
1055
1067
|
async function cmdSweepDormant(pool, boxes, { apply }) {
|
|
1056
1068
|
const rows = await boxes.listBoxes(pool, { state: 'parked' });
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1069
|
+
const now = Date.now();
|
|
1070
|
+
const dormant = boxes.selectDormantBoxes(rows, { dormantDays: CONFIG.dormantDays, nowMs: now });
|
|
1071
|
+
const warnAt = Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0);
|
|
1072
|
+
const warning = boxes.selectDormantWarnBoxes(rows, {
|
|
1073
|
+
dormantDays: CONFIG.dormantDays, warnLeadDays: CONFIG.dormantWarnLeadDays, nowMs: now,
|
|
1074
|
+
});
|
|
1075
|
+
console.log(`dormant sweep (reclaim >${CONFIG.dormantDays}d, warn >${warnAt}d${apply ? '' : ', dry-run'}): ${dormant.length} dormant, ${warning.length} entering the warning window, of ${rows.length} parked`);
|
|
1076
|
+
if (dormant.length === 0 && warning.length === 0) { console.log(' nothing dormant — exit clean'); return { reclaimed: 0, warned: 0, deferred: 0 }; }
|
|
1077
|
+
// Built lazily: a sweep that only WARNS must not require a DO token.
|
|
1078
|
+
let doApi = null;
|
|
1079
|
+
const needDo = () => (doApi || (doApi = makeDo(apply)));
|
|
1080
|
+
let reclaimed = 0, errors = 0, pingFailed = 0, warned = 0, deferred = 0;
|
|
1081
|
+
|
|
1082
|
+
const sendWarning = async (box, days) => {
|
|
1083
|
+
const left = Math.max(CONFIG.dormantDays - days, 0);
|
|
1084
|
+
const ping = postReclaimPing(`@${box.github_login} — your dev box has been parked ${days} days. Its saved disk image will be reclaimed in ${left} day(s) to save cost, and anything on it you haven't pushed will be gone with it. Wake it any time with POST /api/gds/box/ensure to keep it.`);
|
|
1085
|
+
if (!ping.ok) { pingFailed++; return false; }
|
|
1086
|
+
await boxes.recordEvent(pool, {
|
|
1087
|
+
boxId: box.id, builderId: box.builder_id, event: 'dormant_warning',
|
|
1088
|
+
detail: `parked ${days}d; reclaim in ${left}d`, actor: 'sweep:dormant',
|
|
1089
|
+
});
|
|
1090
|
+
warned++;
|
|
1091
|
+
return true;
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
for (const box of warning) {
|
|
1095
|
+
const days = Math.round((now - Date.parse(box.parked_at)) / 86400000);
|
|
1096
|
+
if (!apply) { console.log(` would warn @${box.github_login} (parked ${days}d)`); continue; }
|
|
1097
|
+
if (await boxes.hasDormantWarning(pool, { boxId: box.id, parkedAt: box.parked_at })) continue;
|
|
1098
|
+
const ok = await sendWarning(box, days);
|
|
1099
|
+
console.log(` ${ok ? '✓ warned' : '✖ warning FAILED for'} @${box.github_login} (parked ${days}d)`);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1062
1102
|
for (const box of dormant) {
|
|
1063
|
-
const days = Math.round((
|
|
1103
|
+
const days = Math.round((now - Date.parse(box.parked_at)) / 86400000);
|
|
1064
1104
|
if (!apply) { console.log(` would reclaim @${box.github_login}'s box (parked ${days}d, snapshot ${box.snapshot_id})`); continue; }
|
|
1105
|
+
// The gate: no receipt, no reclaim. Warn now, let a later sweep take it.
|
|
1106
|
+
if (!(await boxes.hasDormantWarning(pool, { boxId: box.id, parkedAt: box.parked_at }))) {
|
|
1107
|
+
deferred++;
|
|
1108
|
+
const ok = await sendWarning(box, days);
|
|
1109
|
+
console.log(` ⏸ deferred @${box.github_login} (parked ${days}d) — never warned; ${ok ? 'warning sent, reclaim next sweep' : 'warning FAILED, will retry'}`);
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1065
1112
|
try {
|
|
1066
|
-
await deprovisionOne(pool, boxes,
|
|
1113
|
+
await deprovisionOne(pool, boxes, needDo(), box, { actor: 'sweep:dormant' });
|
|
1067
1114
|
reclaimed++;
|
|
1068
|
-
const ping = postReclaimPing(`@${box.github_login} — your dev box was
|
|
1115
|
+
const ping = postReclaimPing(`@${box.github_login} — your dev box was parked for ${days} days and has now been reclaimed to save cost, as warned. Queue a fresh one anytime with POST /api/gds/box/ensure (no operator needed); your memory is server-side and unaffected.`);
|
|
1069
1116
|
if (!ping.ok) pingFailed++;
|
|
1070
1117
|
console.log(` ✓ reclaimed @${box.github_login} (parked ${days}d)${ping.ok ? '' : ' [chat ping failed]'}`);
|
|
1071
1118
|
} catch (e) { errors++; await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message); console.log(` ✖ @${box.github_login}: ${e.message}`); }
|
|
1072
1119
|
}
|
|
1073
|
-
console.log(`done — reclaimed=${reclaimed} ping-failed=${pingFailed} errors=${errors} dry-run=${!apply}`);
|
|
1074
|
-
return { reclaimed, errors, pingFailed };
|
|
1120
|
+
console.log(`done — reclaimed=${reclaimed} warned=${warned} deferred=${deferred} ping-failed=${pingFailed} errors=${errors} dry-run=${!apply}`);
|
|
1121
|
+
return { reclaimed, warned, deferred, errors, pingFailed };
|
|
1075
1122
|
}
|
|
1076
1123
|
|
|
1077
1124
|
// ---- reconcile (source-access clawback) ------------------------------------
|
|
@@ -1369,7 +1416,8 @@ Commands:
|
|
|
1369
1416
|
run-intents drain the auto-provision/wake-on-connect queue (#701):
|
|
1370
1417
|
execute each pending box_intents row (provision/wake)
|
|
1371
1418
|
sweep-idle park active boxes idle > ${CONFIG.idleMinutes}m
|
|
1372
|
-
sweep-dormant deprovision boxes parked > ${CONFIG.dormantDays}d
|
|
1419
|
+
sweep-dormant warn at ${Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0)}d, then deprovision boxes parked > ${CONFIG.dormantDays}d
|
|
1420
|
+
(never reclaims a box that was not warned first)
|
|
1373
1421
|
reconcile source-access clawback: deprovision below-floor/inactive
|
|
1374
1422
|
boxes, rescope graduated ones (ADR 0031 §6, #600)
|
|
1375
1423
|
reconcile-drift repair DO⇄DB drift BOTH ways (task 1289): force-delete
|
|
@@ -1441,4 +1489,4 @@ async function main() {
|
|
|
1441
1489
|
|
|
1442
1490
|
if (require.main === module) main();
|
|
1443
1491
|
|
|
1444
|
-
module.exports = { cmdProvision, deprovisionOne, parkOne, cmdSweepIdle, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
|
|
1492
|
+
module.exports = { cmdProvision, deprovisionOne, parkOne, cmdSweepIdle, cmdSweepDormant, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
|
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
|
@@ -583,8 +583,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
583
583
|
// landed since 1.19.76 with no explicit bump. run 31647860631. (task 1002620)
|
|
584
584
|
// 1.19.78 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
585
585
|
// landed since 1.19.77 with no explicit bump. run 31648127800. (task 1002620)
|
|
586
|
+
// 1.19.79 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
586
590
|
// ---------------------------------------------------------------------------
|
|
587
|
-
const CORE_VERSION = '1.19.
|
|
591
|
+
const CORE_VERSION = '1.19.80'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
588
592
|
|
|
589
593
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
590
594
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/boxes.mjs
CHANGED
|
@@ -1332,5 +1332,97 @@ await ta('cmdSweepIdle: a box running Claude is never parked mid-session', async
|
|
|
1332
1332
|
assert.equal(h.states.length, 0);
|
|
1333
1333
|
});
|
|
1334
1334
|
|
|
1335
|
+
console.log('\ntask 1002727 — the dormant sweep warns before it takes:');
|
|
1336
|
+
|
|
1337
|
+
const DORMANT_DAY = 86_400_000;
|
|
1338
|
+
const parked = (d, extra = {}) => ({
|
|
1339
|
+
id: 9, builder_id: 9, state: 'parked', github_login: 'sleeper', snapshot_id: 's1',
|
|
1340
|
+
parked_at: new Date(NOW - d * DORMANT_DAY).toISOString(), ...extra,
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
t('selectDormantWarnBoxes: picks boxes in the lead window, not before, not after', () => {
|
|
1344
|
+
const rows = [parked(10), parked(12), parked(15), parked(3)];
|
|
1345
|
+
const warn = boxes.selectDormantWarnBoxes(rows, { dormantDays: 14, warnLeadDays: 3, nowMs: NOW });
|
|
1346
|
+
const days = warn.map((r) => Math.round((NOW - Date.parse(r.parked_at)) / DORMANT_DAY)).sort((a, b) => a - b);
|
|
1347
|
+
assert.deepEqual(days, [12], 'only the box inside [11d, 14d) is warned');
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
t('selectDormantWarnBoxes: a box already past the threshold is NOT in the warn set', () => {
|
|
1351
|
+
// It belongs to selectDormantBoxes, which refuses to reclaim it unwarned.
|
|
1352
|
+
const warn = boxes.selectDormantWarnBoxes([parked(20)], { dormantDays: 14, warnLeadDays: 3, nowMs: NOW });
|
|
1353
|
+
assert.equal(warn.length, 0);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
t('selectDormantWarnBoxes: guards bad inputs', () => {
|
|
1357
|
+
assert.deepEqual(boxes.selectDormantWarnBoxes(null, { dormantDays: 14, warnLeadDays: 3, nowMs: NOW }), []);
|
|
1358
|
+
assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 0, warnLeadDays: 3, nowMs: NOW }), []);
|
|
1359
|
+
assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 14, warnLeadDays: 0, nowMs: NOW }), []);
|
|
1360
|
+
assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 14, warnLeadDays: 3, nowMs: NaN }), []);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
await ta('hasDormantWarning: keyed on the CURRENT parked interval, so a re-park re-arms', async () => {
|
|
1364
|
+
const seen = [];
|
|
1365
|
+
const db = { async query(sql, params) { seen.push({ sql, params }); return { rows: [] }; } };
|
|
1366
|
+
const parkedAt = new Date(NOW - 5 * DORMANT_DAY).toISOString();
|
|
1367
|
+
const got = await boxes.hasDormantWarning(db, { boxId: 9, parkedAt });
|
|
1368
|
+
assert.equal(got, false, 'no receipt → not warned');
|
|
1369
|
+
assert.match(seen[0].sql, /dormant_warning/);
|
|
1370
|
+
assert.match(seen[0].sql, /created_at >= \$2/, 'scoped to events AFTER this park, not any historical one');
|
|
1371
|
+
assert.deepEqual(seen[0].params, [9, parkedAt]);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
await ta('hasDormantWarning: a missing box id or parked_at is false, never a query', async () => {
|
|
1375
|
+
const db = { async query() { throw new Error('should not query'); } };
|
|
1376
|
+
assert.equal(await boxes.hasDormantWarning(db, { boxId: null, parkedAt: 'x' }), false);
|
|
1377
|
+
assert.equal(await boxes.hasDormantWarning(db, { boxId: 9, parkedAt: null }), false);
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
await ta('cmdSweepDormant: a box past the threshold that was NEVER warned is DEFERRED, not reclaimed', async () => {
|
|
1381
|
+
// The safety property: even if the warning window was missed entirely (runner
|
|
1382
|
+
// downtime, chat outage), the snapshot is not destroyed on this pass.
|
|
1383
|
+
const box = parked(30);
|
|
1384
|
+
const destroyed = [];
|
|
1385
|
+
const events = [];
|
|
1386
|
+
const fakeBoxes = {
|
|
1387
|
+
async listBoxes() { return [box]; },
|
|
1388
|
+
selectDormantBoxes: boxes.selectDormantBoxes,
|
|
1389
|
+
selectDormantWarnBoxes: boxes.selectDormantWarnBoxes,
|
|
1390
|
+
async hasDormantWarning() { return false; }, // never warned
|
|
1391
|
+
async recordEvent(_db, e) { events.push(e); },
|
|
1392
|
+
async setBoxState() { return {}; },
|
|
1393
|
+
async recordComputeCost() { return 0; },
|
|
1394
|
+
async recordStorageCost() { return 0; },
|
|
1395
|
+
async getBoxByBuilderId() { return { state: 'parked' }; },
|
|
1396
|
+
};
|
|
1397
|
+
const lines = [];
|
|
1398
|
+
const origLog = console.log;
|
|
1399
|
+
console.log = (...a) => lines.push(a.join(' '));
|
|
1400
|
+
let res;
|
|
1401
|
+
try { res = await boxCli.cmdSweepDormant({}, fakeBoxes, { apply: true }); }
|
|
1402
|
+
finally { console.log = origLog; }
|
|
1403
|
+
assert.equal(res.reclaimed, 0, 'NOTHING reclaimed without a delivered warning');
|
|
1404
|
+
assert.equal(res.deferred, 1, 'the reclaim is deferred to a later sweep');
|
|
1405
|
+
assert.equal(destroyed.length, 0);
|
|
1406
|
+
assert.ok(lines.some((l) => /deferred/.test(l)), 'the deferral is logged, not silent');
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
await ta('cmdSweepDormant: dry-run reclaims and warns nothing', async () => {
|
|
1410
|
+
const fakeBoxes = {
|
|
1411
|
+
async listBoxes() { return [parked(30), parked(12)]; },
|
|
1412
|
+
selectDormantBoxes: boxes.selectDormantBoxes,
|
|
1413
|
+
selectDormantWarnBoxes: boxes.selectDormantWarnBoxes,
|
|
1414
|
+
async hasDormantWarning() { return true; },
|
|
1415
|
+
async recordEvent() { throw new Error('dry-run must not write'); },
|
|
1416
|
+
async setBoxState() { throw new Error('dry-run must not write'); },
|
|
1417
|
+
};
|
|
1418
|
+
const origLog = console.log;
|
|
1419
|
+
console.log = () => {};
|
|
1420
|
+
let res;
|
|
1421
|
+
try { res = await boxCli.cmdSweepDormant({}, fakeBoxes, { apply: false }); }
|
|
1422
|
+
finally { console.log = origLog; }
|
|
1423
|
+
assert.equal(res.reclaimed, 0);
|
|
1424
|
+
assert.equal(res.warned, 0);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1335
1427
|
console.log(`\nboxes.mjs: ${passed} passed, ${failed} failed`);
|
|
1336
1428
|
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
|
+
});
|