project-nomad/admin/tests/unit/kb_guardrail.spec.ts
Chris Sherwood cf3a924b9f feat(KB): guardrail modal at 50GB / 10%-free thresholds (RFC #883 §7)
One-time confirmation step gating bulk indexing actions that would
consume a substantial amount of disk for embedding storage. Fires only
when the user has policy=Always (i.e., the system would auto-index)
AND the estimate trips either:

  - GUARDRAIL_ABSOLUTE_BYTES = 50 GB embedding cost, OR
  - GUARDRAIL_FREE_DISK_RATIO = 10% of current free disk space

Under policy=Manual the guardrail is silent because the user has
already opted out of automatic ingestion — the files would just queue
as pending_decision either way.

Pieces
- inertia/lib/kb_guardrail.ts: pure decision helper with two constants
  and an evaluateGuardrail() that returns a verdict + reasons. No I/O
  on the helper itself so the logic is trivially testable
- inertia/components/KbGuardrailModal.tsx: confirmation dialog. Headless
  UI Transition + Dialog, amber 'large operation' header, plain-English
  estimate summary, [Cancel] / [Proceed anyway] footer. z-[60] so it
  layers above the tier modal underneath instead of replacing it
- inertia/components/TierSelectionModal.tsx integration: handleSubmit
  now evaluates the guardrail when policy=Always and embedEstimate is
  available; if it trips, we stash the verdict in state and render the
  guardrail modal as an overlay. Confirm runs finalizeSubmit (which is
  the pre-existing onSelectTier + onClose path); Cancel just closes the
  guardrail and leaves the tier modal as-is so the user can change
  their tier choice or flip the policy

The disk-free signal comes from the existing useSystemInfo hook +
getPrimaryDiskInfo helper. Passing freeBytes=0 (unknown) skips the
relative-disk check, so the modal still works on hosts whose disk
introspection failed — just relies on the absolute 50 GB threshold

Tests
- 9 cases in tests/unit/kb_guardrail.spec.ts: standard small batch (no
  trip), exact absolute threshold trips, over-absolute trips, over 10%
  free trips, both-at-once trips with two reasons, freeBytes=0 skip,
  freeBytes=0 + over-absolute trip, exact-10% boundary trips, just-
  under-both safe. All green.

Stacks on feat/kb-tier-estimate-on-disk (#897) — consumes that PR's
estimate endpoint to compute the verdict input. Auto-rebases to rc
when #897 merges.

Pairs with #894 (policy toggle) and #899 (JIT prompt): together the
three PRs cover the 'how do I avoid surprising the user with auto-
indexing they didn't ask for?' arc.

Out of scope (deferred)
- 6 hr time threshold (RFC §7): needs a per-host chunks-per-second
  metric we don't capture yet; would be a follow-up after Phase 4
  self-calibration (RFC §15) lands
- Wider integration (KbPolicyPromptBanner 'Index now' button, manual
  KB-modal sync): TierSelectionModal is the dominant bulk-decision
  surface and the right place to land this first
2026-05-20 10:16:00 -07:00

107 lines
3.4 KiB
TypeScript

import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import {
GUARDRAIL_ABSOLUTE_BYTES,
GUARDRAIL_FREE_DISK_RATIO,
evaluateGuardrail,
} from '../../inertia/lib/kb_guardrail.js'
const GB = 1024 * 1024 * 1024
test('small batch does not trip the guardrail', () => {
const verdict = evaluateGuardrail({
estimateBytes: 1 * GB, // 1 GB
freeBytes: 500 * GB,
})
assert.equal(verdict.trips, false)
assert.deepEqual(verdict.reasons, [])
})
test('batch at exactly the absolute threshold trips', () => {
const verdict = evaluateGuardrail({
estimateBytes: GUARDRAIL_ABSOLUTE_BYTES,
freeBytes: 1000 * GB,
})
assert.equal(verdict.trips, true)
assert.equal(verdict.reasons.length, 1)
assert.equal(verdict.reasons[0].kind, 'over_absolute')
})
test('batch over the absolute threshold trips with over_absolute reason', () => {
const verdict = evaluateGuardrail({
estimateBytes: 60 * GB,
freeBytes: 1000 * GB,
})
const overAbsolute = verdict.reasons.find((r) => r.kind === 'over_absolute')
assert.ok(overAbsolute, 'should include over_absolute reason')
assert.equal(verdict.trips, true)
})
test('batch over 10% of free disk trips with over_free_disk reason', () => {
// 5 GB estimate against 40 GB free disk -> 5 > 4 (10% of 40)
const verdict = evaluateGuardrail({
estimateBytes: 5 * GB,
freeBytes: 40 * GB,
})
const overFree = verdict.reasons.find((r) => r.kind === 'over_free_disk')
assert.ok(overFree, 'should include over_free_disk reason')
assert.equal(verdict.trips, true)
})
test('batch can trip BOTH thresholds simultaneously', () => {
// 100 GB estimate, 200 GB free
// - over absolute (100 > 50)
// - over 10% of free (100 > 20)
const verdict = evaluateGuardrail({
estimateBytes: 100 * GB,
freeBytes: 200 * GB,
})
assert.equal(verdict.trips, true)
assert.equal(verdict.reasons.length, 2)
assert.ok(verdict.reasons.some((r) => r.kind === 'over_absolute'))
assert.ok(verdict.reasons.some((r) => r.kind === 'over_free_disk'))
})
test('freeBytes = 0 skips the relative-disk check', () => {
// 100 MB estimate, no free-disk signal: only the absolute check runs,
// and 100 MB is well below the 50 GB absolute threshold
const verdict = evaluateGuardrail({
estimateBytes: 100 * 1024 * 1024,
freeBytes: 0,
})
assert.equal(verdict.trips, false)
})
test('freeBytes = 0 still trips the absolute check at 50 GB', () => {
const verdict = evaluateGuardrail({
estimateBytes: 100 * GB,
freeBytes: 0,
})
assert.equal(verdict.trips, true)
assert.equal(verdict.reasons.length, 1)
assert.equal(verdict.reasons[0].kind, 'over_absolute')
})
test('relative-disk threshold computed from GUARDRAIL_FREE_DISK_RATIO constant', () => {
// Estimate exactly equal to 10% of free -> trips (>=)
const free = 100 * GB
const exactlyTenPercent = free * GUARDRAIL_FREE_DISK_RATIO
const verdict = evaluateGuardrail({
estimateBytes: exactlyTenPercent,
freeBytes: free,
})
const overFree = verdict.reasons.find((r) => r.kind === 'over_free_disk')
assert.ok(overFree, 'should trip at exactly the threshold')
})
test('batch just under both thresholds does not trip', () => {
// 4 GB estimate vs 50 GB free -> 10% of 50 = 5 GB, so 4 < 5
// Also well below 50 GB absolute
const verdict = evaluateGuardrail({
estimateBytes: 4 * GB,
freeBytes: 50 * GB,
})
assert.equal(verdict.trips, false)
})