@bongos/core 1.19.78 → 1.19.79
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 +11 -11
- package/modules/dev-box/boxes.js +40 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box.js +61 -13
- package/src/module-api.js +3 -1
- package/tests/boxes.mjs +92 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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.79",
|
|
6
|
+
"core_contract": "1.19.79",
|
|
7
|
+
"source_commit": "ba59ac00319af3e83900b468e2feac5038806c60",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-12T22:44
|
|
9
|
+
"built_at": "2026-08-12T22:56:44.247Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 295,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 1541,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "351a95bc990504fb425ec46898380ecd98b6518301c81afc6046244459dd15e0",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/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",
|
|
@@ -4057,12 +4057,12 @@
|
|
|
4057
4057
|
{
|
|
4058
4058
|
"path": "package-lock.json",
|
|
4059
4059
|
"mode": "0000644",
|
|
4060
|
-
"sha256": "
|
|
4060
|
+
"sha256": "c96137763abb2061735f0a815401ead7047d116c5eb6dd538178e9e23c9ca0bd"
|
|
4061
4061
|
},
|
|
4062
4062
|
{
|
|
4063
4063
|
"path": "package.json",
|
|
4064
4064
|
"mode": "0000644",
|
|
4065
|
-
"sha256": "
|
|
4065
|
+
"sha256": "97227cfa1c95b171af85bd2f248f894ebe6f8795cd9364b6dc87180fc00e445e"
|
|
4066
4066
|
},
|
|
4067
4067
|
{
|
|
4068
4068
|
"path": "public-docs/index.html",
|
|
@@ -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",
|
|
@@ -5687,7 +5687,7 @@
|
|
|
5687
5687
|
{
|
|
5688
5688
|
"path": "src/module-api.js",
|
|
5689
5689
|
"mode": "0000644",
|
|
5690
|
-
"sha256": "
|
|
5690
|
+
"sha256": "ff17cf82d49bb2e21188381d9621bbf4f5cf61ac3e49b10ea5ea32685d170c14"
|
|
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",
|
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,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.79",
|
|
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.79",
|
|
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.79",
|
|
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/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/src/module-api.js
CHANGED
|
@@ -583,8 +583,10 @@ 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)
|
|
586
588
|
// ---------------------------------------------------------------------------
|
|
587
|
-
const CORE_VERSION = '1.19.
|
|
589
|
+
const CORE_VERSION = '1.19.79'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
588
590
|
|
|
589
591
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
590
592
|
// 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);
|