@bongos/core 1.19.77 → 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 +16 -16
- package/modules/dev-box/box-access.js +30 -7
- package/modules/dev-box/boxes.js +40 -0
- package/modules/dev-box/routes/box.js +27 -2
- package/modules/lifecycle/db.js +24 -0
- package/modules/lifecycle/lifecycle.js +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box.js +61 -13
- package/src/module-api.js +5 -1
- package/tests/box_task_scope.mjs +70 -0
- 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:
|
|
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",
|
|
@@ -2582,7 +2582,7 @@
|
|
|
2582
2582
|
{
|
|
2583
2583
|
"path": "modules/dev-box/box-access.js",
|
|
2584
2584
|
"mode": "0000644",
|
|
2585
|
-
"sha256": "
|
|
2585
|
+
"sha256": "8d2dc69a139a5a658866a18527efeb5d9ae989e05883fa6cbd0ea63db0ba9476"
|
|
2586
2586
|
},
|
|
2587
2587
|
{
|
|
2588
2588
|
"path": "modules/dev-box/box-credential.js",
|
|
@@ -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",
|
|
@@ -2612,7 +2612,7 @@
|
|
|
2612
2612
|
{
|
|
2613
2613
|
"path": "modules/dev-box/routes/box.js",
|
|
2614
2614
|
"mode": "0000644",
|
|
2615
|
-
"sha256": "
|
|
2615
|
+
"sha256": "ba75c28d8927ebbda1482f394a0062febc6e32a52863814d00d6ad2a6667a591"
|
|
2616
2616
|
},
|
|
2617
2617
|
{
|
|
2618
2618
|
"path": "modules/discord/CLAUDE.md",
|
|
@@ -3402,7 +3402,7 @@
|
|
|
3402
3402
|
{
|
|
3403
3403
|
"path": "modules/lifecycle/db.js",
|
|
3404
3404
|
"mode": "0000644",
|
|
3405
|
-
"sha256": "
|
|
3405
|
+
"sha256": "5e7dc6c267384eb3b691f49ec82631425151b97d2208eaf342922bf46146965f"
|
|
3406
3406
|
},
|
|
3407
3407
|
{
|
|
3408
3408
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -3437,7 +3437,7 @@
|
|
|
3437
3437
|
{
|
|
3438
3438
|
"path": "modules/lifecycle/lifecycle.js",
|
|
3439
3439
|
"mode": "0000644",
|
|
3440
|
-
"sha256": "
|
|
3440
|
+
"sha256": "8f62f50c5a81d649df6b85ef7c89e57a0bf0e8877482693861a4f9cd5cfafe5a"
|
|
3441
3441
|
},
|
|
3442
3442
|
{
|
|
3443
3443
|
"path": "modules/lifecycle/merge-lock.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",
|
|
@@ -6032,7 +6032,7 @@
|
|
|
6032
6032
|
{
|
|
6033
6033
|
"path": "tests/box_task_scope.mjs",
|
|
6034
6034
|
"mode": "0000644",
|
|
6035
|
-
"sha256": "
|
|
6035
|
+
"sha256": "22a2dc8bdccb9900b642371b85e8cc1785ecfacaf8f7579b042951425d95d794"
|
|
6036
6036
|
},
|
|
6037
6037
|
{
|
|
6038
6038
|
"path": "tests/box_terminal_ssrf.mjs",
|
|
@@ -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",
|
|
@@ -203,18 +203,33 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
|
|
|
203
203
|
// + passed by the route in R94) selects the surface, three ways:
|
|
204
204
|
// - scopeKeys is a NON-EMPTY array → TASK scope: BASE ∪ the claims' module dirs
|
|
205
205
|
// (via sparsePathsForScopeKeys + the passed-in `moduleScopeMap`).
|
|
206
|
-
// - scopeKeys is an EMPTY array →
|
|
207
|
-
//
|
|
206
|
+
// - scopeKeys is an EMPTY array → depends on whether the builder is actually
|
|
207
|
+
// working (see `hasActiveClaims` below).
|
|
208
208
|
// - scopeKeys is ABSENT (undefined) → the pre-ADR-0148 RANK fallback (full for
|
|
209
209
|
// Metic+, else the starter sparse surface). This is the path a vanilla instance
|
|
210
210
|
// with no lifecycle port takes, and it preserves the old behavior EXACTLY — so
|
|
211
211
|
// shipping R93 before R94 wires the route changes nothing in production.
|
|
212
212
|
//
|
|
213
|
+
// TASK 1002729 — the empty-array case is NOT one case, it is two, and collapsing
|
|
214
|
+
// them was a live defect. moduleScopeForActiveClaims returns [] both when the
|
|
215
|
+
// builder holds no claim AND when they hold claims whose goals declare no
|
|
216
|
+
// scope_modules (the goal wall is optional; only 13 of 46 goals on the live
|
|
217
|
+
// instance declared one). Treating the second as "no scope" handed a working
|
|
218
|
+
// builder a box with NO modules/ directory at all — less code than the rank
|
|
219
|
+
// fallback would have given a newcomer. On the live fleet that was 247 of 248
|
|
220
|
+
// source grants, including an Archon holding 7 active claims.
|
|
221
|
+
// - [] + hasActiveClaims true → RANK fallback. The builder is working; an
|
|
222
|
+
// undeclared goal wall is a planning gap, and it must not cripple their box.
|
|
223
|
+
// - [] + hasActiveClaims false → BASE-only, as before (a genuinely unscoped box
|
|
224
|
+
// — the pre-existing Bongos instance, nothing extra). Least privilege holds.
|
|
225
|
+
// `hasActiveClaims` is optional: omitted (undefined) preserves the old BASE-only
|
|
226
|
+
// reading, so a caller that hasn't been taught the signal is unchanged.
|
|
227
|
+
//
|
|
213
228
|
// Returns one of:
|
|
214
229
|
// { allowed:false, reason:'BUILDER_INACTIVE' }
|
|
215
230
|
// { allowed:false, reason:'INSUFFICIENT_RANK', required, actual }
|
|
216
231
|
// { allowed:true, scope, spec }
|
|
217
|
-
function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, moduleScopeMap } = {}) {
|
|
232
|
+
function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap } = {}) {
|
|
218
233
|
if (status && status !== 'active') {
|
|
219
234
|
return { allowed: false, reason: 'BUILDER_INACTIVE' };
|
|
220
235
|
}
|
|
@@ -228,11 +243,19 @@ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, sco
|
|
|
228
243
|
}
|
|
229
244
|
// Claim-driven task scope (present → the box asks for exactly its work's code).
|
|
230
245
|
if (Array.isArray(scopeKeys)) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
246
|
+
if (scopeKeys.length > 0) {
|
|
247
|
+
const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
|
|
248
|
+
return { allowed: true, scope: 'task', spec: { scope: 'task', mode: 'sparse', sparsePaths } };
|
|
249
|
+
}
|
|
250
|
+
// Empty. A builder mid-claim whose goals declare no wall gets the rank scope,
|
|
251
|
+
// NOT a stripped box — the planning gap is not theirs to pay for.
|
|
252
|
+
if (!hasActiveClaims) {
|
|
253
|
+
const sparsePaths = sparsePathsForScopeKeys([], moduleScopeMap);
|
|
254
|
+
return { allowed: true, scope: 'base', spec: { scope: 'base', mode: 'sparse', sparsePaths } };
|
|
255
|
+
}
|
|
234
256
|
}
|
|
235
|
-
// No claim signal (no lifecycle port on this instance)
|
|
257
|
+
// No claim signal (no lifecycle port on this instance), or claims with an
|
|
258
|
+
// undeclared goal wall: the pre-ADR-0148 rank fallback.
|
|
236
259
|
const scope = boxScopeForRank(rank);
|
|
237
260
|
return { allowed: true, scope, spec: cloneSpecForScope(scope) };
|
|
238
261
|
}
|
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,
|
|
@@ -444,6 +444,7 @@ module.exports = function buildBoxRouter() {
|
|
|
444
444
|
// NEVER fail the source fetch on a scope-read hiccup — degrade to rank scope.
|
|
445
445
|
const lifecycle = api.resolveOptional('lifecycle');
|
|
446
446
|
let scopeKeys;
|
|
447
|
+
let hasActiveClaims;
|
|
447
448
|
if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
|
|
448
449
|
try {
|
|
449
450
|
scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
|
|
@@ -452,7 +453,19 @@ module.exports = function buildBoxRouter() {
|
|
|
452
453
|
scopeKeys = undefined;
|
|
453
454
|
}
|
|
454
455
|
}
|
|
455
|
-
|
|
456
|
+
// task 1002729 — only needed to disambiguate an EMPTY scope: no claim (BASE)
|
|
457
|
+
// vs claims whose goals declare no wall (rank scope). Skipped otherwise so
|
|
458
|
+
// the common path keeps its single query.
|
|
459
|
+
if (Array.isArray(scopeKeys) && scopeKeys.length === 0
|
|
460
|
+
&& lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
|
|
461
|
+
try {
|
|
462
|
+
hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
|
|
463
|
+
} catch (claimErr) {
|
|
464
|
+
console.error('[gds] source-access claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
|
|
465
|
+
hasActiveClaims = undefined;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
|
|
456
469
|
const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
457
470
|
const auditDeny = (reason, extra) =>
|
|
458
471
|
box && boxAccess.recordSourceEvent(pool, {
|
|
@@ -548,6 +561,7 @@ module.exports = function buildBoxRouter() {
|
|
|
548
561
|
// (optional lifecycle port; degrade to rank scope on any hiccup).
|
|
549
562
|
const lifecycle = api.resolveOptional('lifecycle');
|
|
550
563
|
let scopeKeys;
|
|
564
|
+
let hasActiveClaims;
|
|
551
565
|
if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
|
|
552
566
|
try {
|
|
553
567
|
scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
|
|
@@ -556,7 +570,18 @@ module.exports = function buildBoxRouter() {
|
|
|
556
570
|
scopeKeys = undefined;
|
|
557
571
|
}
|
|
558
572
|
}
|
|
559
|
-
|
|
573
|
+
// task 1002729 — same disambiguation as source-access, so this read-only
|
|
574
|
+
// companion can never report a scope the real fetch wouldn't grant.
|
|
575
|
+
if (Array.isArray(scopeKeys) && scopeKeys.length === 0
|
|
576
|
+
&& lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
|
|
577
|
+
try {
|
|
578
|
+
hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
|
|
579
|
+
} catch (claimErr) {
|
|
580
|
+
console.error('[gds] /box/scope claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
|
|
581
|
+
hasActiveClaims = undefined;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
|
|
560
585
|
if (!decision.allowed) {
|
|
561
586
|
return res.fail('SOURCE_ACCESS_REVOKED', 403, {
|
|
562
587
|
reason: decision.reason,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -4756,6 +4756,29 @@ async function moduleScopeForActiveClaims(builderId, deps = {}) {
|
|
|
4756
4756
|
return rows.map((r) => r.module_key);
|
|
4757
4757
|
}
|
|
4758
4758
|
|
|
4759
|
+
// Task 1002729 — the companion signal moduleScopeForActiveClaims cannot carry.
|
|
4760
|
+
// That query INNER JOINs goals and unnests scope_modules, so it returns [] for
|
|
4761
|
+
// BOTH "this builder holds no claim" and "this builder holds claims whose goals
|
|
4762
|
+
// declare no scope_modules". Those must not be treated alike: the first is a
|
|
4763
|
+
// genuinely unscoped box (BASE-only is right), the second is a builder actively
|
|
4764
|
+
// working who would be handed a box with no modules/ at all. Live evidence for
|
|
4765
|
+
// why this matters: 247 of 248 source grants on the cloudbongos fleet resolved to
|
|
4766
|
+
// BASE, including an Archon holding 7 active claims — every one of those claims
|
|
4767
|
+
// sat on a goal with scope_modules = {}. Counts CLAIMS, deliberately not joining
|
|
4768
|
+
// goals, so it stays true regardless of how the goal wall is (or isn't) declared.
|
|
4769
|
+
async function activeClaimCountForBuilder(builderId, deps = {}) {
|
|
4770
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
4771
|
+
const id = Number(builderId);
|
|
4772
|
+
if (!Number.isInteger(id)) {
|
|
4773
|
+
throw new Error('activeClaimCountForBuilder: builderId must be numeric');
|
|
4774
|
+
}
|
|
4775
|
+
const { rows } = await activePool.query(
|
|
4776
|
+
`SELECT count(*)::int AS n FROM claims WHERE builder_id = $1 AND released_at IS NULL`,
|
|
4777
|
+
[id]
|
|
4778
|
+
);
|
|
4779
|
+
return rows.length ? Number(rows[0].n) : 0;
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4759
4782
|
// task 1642 — every active claim bound to a given CLI session. "Active" is the
|
|
4760
4783
|
// claims-table convention (released_at IS NULL); there is NO `status` column on
|
|
4761
4784
|
// claims (a common misread — the task's status lives on `tasks`). Keyed on
|
|
@@ -5625,6 +5648,7 @@ module.exports = {
|
|
|
5625
5648
|
getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
|
|
5626
5649
|
getActiveClaimsForBuilder,
|
|
5627
5650
|
moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
|
|
5651
|
+
activeClaimCountForBuilder, // task 1002729 — distinguishes "no claim" from "claim with an empty goal wall"
|
|
5628
5652
|
getActiveClaimsForSession,
|
|
5629
5653
|
getLastClaimHolder, // --regrade ownership guard (#678)
|
|
5630
5654
|
getTaskGrade, // V4.R08 — diff-hash reuse check before grading
|
|
@@ -65,4 +65,7 @@ module.exports = {
|
|
|
65
65
|
// resolver (goal 1000051 / R92, ADR 0148 §2). Keys only; box-access maps them to
|
|
66
66
|
// git sparse-checkout globs through the doorway `moduleScopeMap`.
|
|
67
67
|
moduleScopeForActiveClaims: (...a) => db.moduleScopeForActiveClaims(...a),
|
|
68
|
+
// task 1002729 — the "is this builder actually working right now" signal that
|
|
69
|
+
// lets box-access tell an unscoped box from a crippled one (see db.js).
|
|
70
|
+
activeClaimCountForBuilder: (...a) => db.activeClaimCountForBuilder(...a),
|
|
68
71
|
};
|
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
|
@@ -581,8 +581,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
581
581
|
// landed since 1.19.75 with no explicit bump. run 31637719876. (task 1002620)
|
|
582
582
|
// 1.19.77 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
583
583
|
// landed since 1.19.76 with no explicit bump. run 31647860631. (task 1002620)
|
|
584
|
+
// 1.19.78 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
584
588
|
// ---------------------------------------------------------------------------
|
|
585
|
-
const CORE_VERSION = '1.19.
|
|
589
|
+
const CORE_VERSION = '1.19.79'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
586
590
|
|
|
587
591
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
588
592
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/box_task_scope.mjs
CHANGED
|
@@ -96,4 +96,74 @@ await test('an inactive builder is denied even with claims in scope', async () =
|
|
|
96
96
|
assert.equal(inactive.decision.reason, 'BUILDER_INACTIVE');
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
// =========================================================================
|
|
100
|
+
// Task 1002729 — an EMPTY scope is two different situations, not one
|
|
101
|
+
// =========================================================================
|
|
102
|
+
// The live defect: moduleScopeForActiveClaims returns [] both for "holds no
|
|
103
|
+
// claim" and for "holds claims whose goals declare no scope_modules". Reading
|
|
104
|
+
// both as BASE handed working builders a box with no modules/ at all — 247 of
|
|
105
|
+
// 248 grants on the live fleet, including an Archon with 7 active claims.
|
|
106
|
+
|
|
107
|
+
// A pool that answers BOTH queries the resolver pair now issues: the
|
|
108
|
+
// claims→goals→scope_modules unnest, and the plain active-claim count.
|
|
109
|
+
function poolReturningWithClaims(moduleKeyRows, claimCount) {
|
|
110
|
+
const c = makeSqlAwareClient((s) => {
|
|
111
|
+
if (/FROM claims c/.test(s) && /unnest\(g\.scope_modules\)/.test(s)) return { rows: moduleKeyRows };
|
|
112
|
+
if (/count\(\*\)/.test(s) && /FROM claims/.test(s)) return { rows: [{ n: claimCount }] };
|
|
113
|
+
return null;
|
|
114
|
+
});
|
|
115
|
+
return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function specForClaimsWithCount({ builderId = 3, rank = 'metic', status = 'active', moduleKeyRows, claimCount }) {
|
|
119
|
+
const pool = poolReturningWithClaims(moduleKeyRows, claimCount);
|
|
120
|
+
const scopeKeys = await db.moduleScopeForActiveClaims(builderId, { pool });
|
|
121
|
+
const hasActiveClaims = (await db.activeClaimCountForBuilder(builderId, { pool })) > 0;
|
|
122
|
+
return { scopeKeys, hasActiveClaims, decision: access.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: scopeMap }) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await test('claims held but every goal wall empty → RANK scope, never a stripped box', async () => {
|
|
126
|
+
// Exactly Lars's live shape: 7 active claims, all on goals with scope_modules={}.
|
|
127
|
+
const { scopeKeys, hasActiveClaims, decision } = await specForClaimsWithCount({
|
|
128
|
+
rank: 'archon', moduleKeyRows: [], claimCount: 7,
|
|
129
|
+
});
|
|
130
|
+
assert.deepEqual(scopeKeys, [], 'the union query still yields nothing — the goals declare no wall');
|
|
131
|
+
assert.equal(hasActiveClaims, true, 'but the builder IS working');
|
|
132
|
+
assert.equal(decision.scope, 'full', 'an Archon mid-claim gets the full surface, not base');
|
|
133
|
+
assert.equal(decision.spec.mode, 'full');
|
|
134
|
+
assert.notEqual(decision.scope, 'base', 'the regression this test exists to prevent');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('no claims at all → BASE-only is preserved (least privilege holds)', async () => {
|
|
138
|
+
const { hasActiveClaims, decision } = await specForClaimsWithCount({
|
|
139
|
+
rank: 'archon', moduleKeyRows: [], claimCount: 0,
|
|
140
|
+
});
|
|
141
|
+
assert.equal(hasActiveClaims, false);
|
|
142
|
+
assert.equal(decision.scope, 'base', 'an idle box is still not handed the repo');
|
|
143
|
+
assert.deepEqual(decision.spec.sparsePaths, [...BASE]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await test('a declared goal wall still wins over the rank fallback', async () => {
|
|
147
|
+
// Claims AND a wall → task scope, exactly as before; the fix must not widen this.
|
|
148
|
+
const { decision } = await specForClaimsWithCount({
|
|
149
|
+
rank: 'archon', moduleKeyRows: [{ module_key: 'status-ui' }], claimCount: 2,
|
|
150
|
+
});
|
|
151
|
+
assert.equal(decision.scope, 'task', 'a real wall is still honoured, not overridden by rank');
|
|
152
|
+
assert.deepEqual(decision.spec.sparsePaths, [...BASE, 'modules/status-ui']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await test('a Thetes mid-claim with no wall gets STARTER, not full — rank still bounds it', async () => {
|
|
156
|
+
const { decision } = await specForClaimsWithCount({
|
|
157
|
+
rank: 'thetes', moduleKeyRows: [], claimCount: 1,
|
|
158
|
+
});
|
|
159
|
+
assert.equal(decision.scope, 'starter', 'the fallback is rank-bounded; it is not a blanket widen');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await test('activeClaimCountForBuilder rejects a non-numeric builder id', async () => {
|
|
163
|
+
await assert.rejects(
|
|
164
|
+
() => db.activeClaimCountForBuilder('not-a-number', { pool: poolReturningWithClaims([], 0) }),
|
|
165
|
+
/must be numeric/,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
99
169
|
summary();
|
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);
|