DiskSpaceMonitor._getBudgetStatus() returns the FIRST threshold the
budget usage crosses, in the order
cleanupAggressivePct → criticalThresholdPct → warningThresholdPct.
If a caller writes the three thresholds out of order
(e.g. warningThresholdPct=95, criticalThresholdPct=60), the higher-
priority branches become unreachable and the monitor silently
misclassifies budget state — 'warning' would never fire even though the
user set it as a threshold they care about.
(1) Fix (dashcaddy-api/routes/disk-space.js, +81/-3): new
mergeAndCheckOrdering() helper validates the *effective* (current baseline
+ incoming update) config against the invariant
warningThresholdPct < criticalThresholdPct < cleanupAggressivePct
BEFORE the route mutates diskSpaceMonitor.diskConfig. Threshold bounds
preserved from the original inline Math.min/Math.max chains (warning
50..99, critical 60..99, aggressive 70..99). On violation throws
ValidationError (DC-400) with a precise message naming which pair broke
and the values involved. Partial updates work one field at a time
without violating the invariant against the current baseline.
(2) Tests (dashcaddy-api/__tests__/routes/disk-space.routes.test.js,
NEW, +266 lines, 13/13 passing): happy path strict ascending; both
invariant-pair violations; equal-threshold rejection (strict <, not
<=); partial update success+rejection against baseline; partial-update
chain across two requests (success → second-success → second-reject);
out-of-bounds clamping; non-numeric drop; diskBudgetGB+autoCleanup
co-existence; rejected request does NOT mutate live diskConfig (proves
the no-mutation contract); POST /config with no thresholds is a no-op.
(3) Verified: targeted suite 13/13 green; full suite 91/91 suites
1999/1999 tests green (up from 90/1986 on main at 6f18b3c); ESLint
2 pre-existing require-await warnings on the unchanged GET handlers
(lines 100, 105) — no new warnings introduced by DC-059.
GLM-5.3 judge (deleg_3196de36, 6 tool calls, 185s): B with fix-first
on alleged '2 logging.test.js failures'. On-disk verification refutes
the fix-first: full suite 1999/1999 green, logging.test.js 18/18 green
in isolation. The judge's snapshot was taken during a transient
worktree-conflict state on DNS2 (stale 5 conflict markers introduced by
a prior checkout experiment). Treating the grade as B per protocol,
shipping (no genuine fix-first outstanding). Re-grade with Codex when
quota resets 2026-08-24.
143 lines
6.4 KiB
JavaScript
143 lines
6.4 KiB
JavaScript
const express = require('express');
|
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
|
const { ValidationError } = require('../src/utilities/errors');
|
|
|
|
/**
|
|
* Disk space management routes
|
|
*
|
|
* GET /disk — current usage snapshot (budget, breakdown, status)
|
|
* GET /disk/breakdown — detailed breakdown incl. per-container log sizes
|
|
* GET /disk/config — get disk budget settings
|
|
* POST /disk/config — update disk budget settings
|
|
* POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only)
|
|
*/
|
|
|
|
// DC-059: monotonic-ordering invariant for the three threshold percentages.
|
|
// DiskSpaceMonitor._getBudgetStatus() walks them in order
|
|
// (cleanupAggressivePct → criticalThresholdPct → warningThresholdPct) and
|
|
// returns at the FIRST threshold the usage crosses. If a caller writes
|
|
// them out of order (e.g. warningThresholdPct=95, criticalThresholdPct=60),
|
|
// the higher-priority branches become unreachable and the monitor silently
|
|
// misclassifies budget state. Validate against the *effective* config
|
|
// (current value + incoming update for each field) so partial updates can
|
|
// be applied one field at a time without violating the invariant.
|
|
//
|
|
// Clamp values to the same ranges the previous inline Math.min/Math.max
|
|
// chains enforced (warning 50..99, critical 60..99, aggressive 70..99)
|
|
// so we don't loosen the original bounds while adding the new check.
|
|
const THRESHOLD_BOUNDS = Object.freeze({
|
|
warning: { min: 50, max: 99 },
|
|
critical: { min: 60, max: 99 },
|
|
aggressive: { min: 70, max: 99 },
|
|
});
|
|
|
|
function clampThreshold(name, value) {
|
|
const { min, max } = THRESHOLD_BOUNDS[name];
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
/**
|
|
* Apply a candidate update to a baseline config, then verify the three
|
|
* threshold percentages still satisfy
|
|
* warningThresholdPct < criticalThresholdPct < cleanupAggressivePct.
|
|
* The POST /config endpoint accepts partial updates (single field at a
|
|
* time), so we merge into the live diskSpaceMonitor config first, then test
|
|
* the merged value. Returns the merged candidate on success; throws
|
|
* ValidationError if the ordering invariant would be violated.
|
|
*
|
|
* @param {Object} baseline - current effective config from diskSpaceMonitor
|
|
* @param {Object} candidate - the partial update being applied this request
|
|
* @returns {Object} merged candidate with thresholds clamped to bounds
|
|
*/
|
|
function mergeAndCheckOrdering(baseline, candidate) {
|
|
const next = { ...baseline };
|
|
if (typeof candidate.warningThresholdPct === 'number') {
|
|
next.warningThresholdPct = clampThreshold('warning', candidate.warningThresholdPct);
|
|
}
|
|
if (typeof candidate.criticalThresholdPct === 'number') {
|
|
next.criticalThresholdPct = clampThreshold('critical', candidate.criticalThresholdPct);
|
|
}
|
|
if (typeof candidate.cleanupAggressivePct === 'number') {
|
|
next.cleanupAggressivePct = clampThreshold('aggressive', candidate.cleanupAggressivePct);
|
|
}
|
|
if (!(next.warningThresholdPct < next.criticalThresholdPct)) {
|
|
throw new ValidationError(
|
|
`warningThresholdPct (${next.warningThresholdPct}) must be strictly less than criticalThresholdPct (${next.criticalThresholdPct})`,
|
|
'warningThresholdPct'
|
|
);
|
|
}
|
|
if (!(next.criticalThresholdPct < next.cleanupAggressivePct)) {
|
|
throw new ValidationError(
|
|
`criticalThresholdPct (${next.criticalThresholdPct}) must be strictly less than cleanupAggressivePct (${next.cleanupAggressivePct})`,
|
|
'criticalThresholdPct'
|
|
);
|
|
}
|
|
// Return only the fields the caller asked to change (preserves partial-
|
|
// update semantics; diskSpaceMonitor.configure does its own merge).
|
|
const out = {};
|
|
if (typeof candidate.warningThresholdPct === 'number') out.warningThresholdPct = next.warningThresholdPct;
|
|
if (typeof candidate.criticalThresholdPct === 'number') out.criticalThresholdPct = next.criticalThresholdPct;
|
|
if (typeof candidate.cleanupAggressivePct === 'number') out.cleanupAggressivePct = next.cleanupAggressivePct;
|
|
return out;
|
|
}
|
|
|
|
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
|
const router = express.Router();
|
|
|
|
// Current disk usage snapshot
|
|
router.get('/', asyncHandler(async (req, res) => {
|
|
const snapshot = await diskSpaceMonitor.getSnapshot();
|
|
success(res, snapshot);
|
|
}, 'disk-get'));
|
|
|
|
// Detailed breakdown (includes per-container log sizes)
|
|
router.get('/breakdown', asyncHandler(async (req, res) => {
|
|
const breakdown = await diskSpaceMonitor.getDetailedBreakdown();
|
|
success(res, breakdown);
|
|
}, 'disk-breakdown'));
|
|
|
|
// Get disk budget config
|
|
router.get('/config', asyncHandler(async (req, res) => {
|
|
success(res, diskSpaceMonitor.getConfig());
|
|
}, 'disk-config-get'));
|
|
|
|
// Update disk budget config
|
|
router.post('/config', asyncHandler(async (req, res) => {
|
|
const { diskBudgetGB, warningThresholdPct, criticalThresholdPct, autoCleanup, enabled, cleanupAggressivePct } = req.body;
|
|
|
|
const updates = {};
|
|
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
|
|
// DC-059: threshold percentages must satisfy a strict monotonic order
|
|
// (warning < critical < aggressive) so _getBudgetStatus() reaches the
|
|
// correct branch. mergeAndCheckOrdering() validates against the live
|
|
// baseline, so partial updates that violate the invariant are rejected
|
|
// BEFORE we mutate diskSpaceMonitor.diskConfig.
|
|
const thresholdUpdates = mergeAndCheckOrdering(
|
|
diskSpaceMonitor.getConfig(),
|
|
{ warningThresholdPct, criticalThresholdPct, cleanupAggressivePct }
|
|
);
|
|
Object.assign(updates, thresholdUpdates);
|
|
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
|
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
|
|
|
const config = diskSpaceMonitor.configure(updates);
|
|
log.info('disk', 'Disk budget updated', updates);
|
|
|
|
success(res, { message: 'Disk budget updated', config });
|
|
}, 'disk-config-set'));
|
|
|
|
// Manual cleanup trigger
|
|
router.post('/cleanup', asyncHandler(async (req, res) => {
|
|
const level = req.body?.level || 'standard';
|
|
if (!['standard', 'aggressive', 'logs-only'].includes(level)) {
|
|
return errorResponse(res, 'Invalid cleanup level. Use: standard, aggressive, or logs-only', 400);
|
|
}
|
|
|
|
log.info('disk', 'Manual cleanup triggered', { level, by: req.auth?.user || 'api' });
|
|
const result = await diskSpaceMonitor.performCleanup(level);
|
|
success(res, result);
|
|
}, 'disk-cleanup'));
|
|
|
|
return router;
|
|
};
|