From cf3a924b9ffa4f02b6da43db7d09c6ac7106344a Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Thu, 14 May 2026 16:35:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(KB):=20guardrail=20modal=20at=2050GB=20/?= =?UTF-8?q?=2010%-free=20thresholds=20(RFC=20#883=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/inertia/components/KbGuardrailModal.tsx | 109 ++++++++++++++++++ .../inertia/components/TierSelectionModal.tsx | 65 ++++++++++- admin/inertia/lib/kb_guardrail.ts | 74 ++++++++++++ admin/tests/unit/kb_guardrail.spec.ts | 106 +++++++++++++++++ 4 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 admin/inertia/components/KbGuardrailModal.tsx create mode 100644 admin/inertia/lib/kb_guardrail.ts create mode 100644 admin/tests/unit/kb_guardrail.spec.ts diff --git a/admin/inertia/components/KbGuardrailModal.tsx b/admin/inertia/components/KbGuardrailModal.tsx new file mode 100644 index 0000000..498cc4c --- /dev/null +++ b/admin/inertia/components/KbGuardrailModal.tsx @@ -0,0 +1,109 @@ +import { Fragment } from 'react' +import { Dialog, Transition } from '@headlessui/react' +import { IconAlertTriangle, IconX } from '@tabler/icons-react' +import { formatBytes } from '~/lib/util' +import StyledButton from './StyledButton' +import type { GuardrailVerdict } from '~/lib/kb_guardrail' + +/** + * One-time confirmation modal for bulk indexing actions that trip the + * disk-usage thresholds in `lib/kb_guardrail.ts`. The caller (e.g. + * TierSelectionModal) decides whether to show the modal by evaluating the + * guardrail BEFORE submit; this component just presents the verdict and + * passes the user's choice back via `onConfirm` / `onCancel`. + */ +interface KbGuardrailModalProps { + isOpen: boolean + verdict: GuardrailVerdict + onConfirm: () => void + onCancel: () => void +} + +export default function KbGuardrailModal({ + isOpen, + verdict, + onConfirm, + onCancel, +}: KbGuardrailModalProps) { + // The primary number to surface — every triggered reason carries the same + // estimateBytes, so just grab the first one. `0` is a defensive fallback + // for the (impossible-by-construction) "open with empty verdict" case. + const estimateBytes = verdict.reasons[0]?.estimateBytes ?? 0 + const freeReason = verdict.reasons.find((r) => r.kind === 'over_free_disk') + + return ( + + + +
+ + +
+
+ + +
+
+ + + Confirm large AI indexing operation + +
+ +
+ +
+

+ Indexing this batch for the AI Assistant will use approximately{' '} + {formatBytes(estimateBytes, 1)} of disk space for embeddings, on top of the raw downloads. +

+ + {freeReason && ( +

+ That's more than 10% of your remaining free disk space ({formatBytes(freeReason.freeBytes, 1)} free). Embedding can take several hours and is hard to interrupt cleanly once started. +

+ )} + +

+ If you'd rather review per-item before indexing, cancel here and switch your Auto-index setting to Manual from the Knowledge Base panel. +

+
+ +
+ + Cancel + + + Proceed anyway + +
+
+
+
+
+
+
+ ) +} diff --git a/admin/inertia/components/TierSelectionModal.tsx b/admin/inertia/components/TierSelectionModal.tsx index a55a244..f135f3c 100644 --- a/admin/inertia/components/TierSelectionModal.tsx +++ b/admin/inertia/components/TierSelectionModal.tsx @@ -9,6 +9,10 @@ import api from '~/lib/api' import classNames from 'classnames' import DynamicIcon, { DynamicIconName } from './DynamicIcon' import StyledButton from './StyledButton' +import KbGuardrailModal from './KbGuardrailModal' +import { evaluateGuardrail, type GuardrailVerdict } from '~/lib/kb_guardrail' +import { useSystemInfo } from '~/hooks/useSystemInfo' +import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData' /** * Filename for the embed-estimate registry lookup. Strips the URL path so @@ -81,6 +85,17 @@ const TierSelectionModal: React.FC = ({ queryKey: ['ingestPolicy'], queryFn: () => api.getSetting('rag.defaultIngestPolicy'), }) + + // System info for the disk-free side of the guardrail. Shared queryKey with + // the home / easy-setup pages so we don't refetch when the user already has + // a fresh copy in cache from a sibling component. + const { data: systemInfo } = useSystemInfo({ enabled: true }) + + // Open state for the guardrail modal — separate from the tier modal so the + // user sees the warning as an overlay without losing their tier selection + // underneath. Cancel returns to the tier modal as-is; Proceed closes both + // and runs the original onSelectTier path. + const [guardrailVerdict, setGuardrailVerdict] = useState(null) const ingestPolicy: 'Always' | 'Manual' = ingestPolicySetting?.value === 'Manual' ? 'Manual' : 'Always' @@ -99,16 +114,49 @@ const TierSelectionModal: React.FC = ({ } } - const handleSubmit = () => { - if (!localSelectedSlug) return + // Compute disk-free bytes from system info; 0 means "unknown", which the + // guardrail helper treats as "skip the relative-disk check". + const freeBytes = useMemo(() => { + const primary = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize) + if (!primary) return 0 + return Math.max(0, primary.totalSize - primary.totalUsed) + }, [systemInfo]) - const selectedTier = category.tiers.find(t => t.slug === localSelectedSlug) + /** + * Runs the original onSelectTier-then-onClose flow. Pulled out of + * handleSubmit so the guardrail modal's confirm path can call it after + * the user has consented to the large operation. + */ + const finalizeSubmit = () => { + if (!localSelectedSlug || !category) return + const selectedTier = category.tiers.find((t) => t.slug === localSelectedSlug) if (selectedTier) { onSelectTier(category, selectedTier) } onClose() } + const handleSubmit = () => { + if (!localSelectedSlug || !category) return + + // Guardrail only runs when we have an estimate AND the global policy + // would auto-index this batch. Under Manual the user has already opted + // out of automatic ingestion, so the bulk-disk warning would be a false + // alarm — the files would just queue as pending_decision. + if (ingestPolicy === 'Always' && embedEstimate) { + const verdict = evaluateGuardrail({ + estimateBytes: embedEstimate.totalBytes, + freeBytes, + }) + if (verdict.trips) { + setGuardrailVerdict(verdict) + return + } + } + + finalizeSubmit() + } + return ( @@ -307,6 +355,17 @@ const TierSelectionModal: React.FC = ({ + {guardrailVerdict && ( + { + setGuardrailVerdict(null) + finalizeSubmit() + }} + onCancel={() => setGuardrailVerdict(null)} + /> + )} ) } diff --git a/admin/inertia/lib/kb_guardrail.ts b/admin/inertia/lib/kb_guardrail.ts new file mode 100644 index 0000000..28ab1a3 --- /dev/null +++ b/admin/inertia/lib/kb_guardrail.ts @@ -0,0 +1,74 @@ +/** + * Auto-index guardrail thresholds and pure decision logic (RFC #883 §7). + * + * The guardrail fires when a user is about to commit to a bulk indexing + * action (curated tier change, large multi-file upload, etc.) that would + * use a substantial amount of disk for embedding storage. It's a one-time + * confirmation step at scary thresholds — it doesn't fire for ordinary + * everyday operations. After the user confirms once for a given batch + * the action proceeds as it would have without the guardrail. + * + * Thresholds are intentionally conservative to avoid surprise consumption + * of a user's storage. Tweak both constants if the field experience + * suggests we're nagging users too aggressively. + */ + +/** Absolute upper bound: estimates at or above this trip the guardrail. */ +export const GUARDRAIL_ABSOLUTE_BYTES = 50 * 1024 * 1024 * 1024 // 50 GB + +/** Relative-to-free-disk bound: estimates >= 10% of free disk trip too. */ +export const GUARDRAIL_FREE_DISK_RATIO = 0.1 + +export type GuardrailReason = + | { + kind: 'over_absolute' + estimateBytes: number + thresholdBytes: number + } + | { + kind: 'over_free_disk' + estimateBytes: number + freeBytes: number + thresholdBytes: number + } + +export type GuardrailVerdict = { + trips: boolean + reasons: GuardrailReason[] +} + +/** + * Decide whether a bulk indexing action should be gated behind the + * guardrail modal. Caller passes the precomputed embedding-storage + * estimate (from `KbRatioRegistry.estimateBatch` in #891 / #897) and + * the free-disk figure from system info. Pass `freeBytes = 0` to skip + * the relative-disk check when free space isn't known. + */ +export function evaluateGuardrail(input: { + estimateBytes: number + freeBytes: number +}): GuardrailVerdict { + const reasons: GuardrailReason[] = [] + + if (input.estimateBytes >= GUARDRAIL_ABSOLUTE_BYTES) { + reasons.push({ + kind: 'over_absolute', + estimateBytes: input.estimateBytes, + thresholdBytes: GUARDRAIL_ABSOLUTE_BYTES, + }) + } + + if (input.freeBytes > 0) { + const relativeThreshold = input.freeBytes * GUARDRAIL_FREE_DISK_RATIO + if (input.estimateBytes >= relativeThreshold) { + reasons.push({ + kind: 'over_free_disk', + estimateBytes: input.estimateBytes, + freeBytes: input.freeBytes, + thresholdBytes: relativeThreshold, + }) + } + } + + return { trips: reasons.length > 0, reasons } +} diff --git a/admin/tests/unit/kb_guardrail.spec.ts b/admin/tests/unit/kb_guardrail.spec.ts new file mode 100644 index 0000000..b300bd4 --- /dev/null +++ b/admin/tests/unit/kb_guardrail.spec.ts @@ -0,0 +1,106 @@ +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) +})