diff --git a/apps/settings/routes.py b/apps/settings/routes.py index 16df2d2a4..b64a1bbfc 100644 --- a/apps/settings/routes.py +++ b/apps/settings/routes.py @@ -20,12 +20,14 @@ from convey import state from think.providers.google import validate_vertex_credentials from think.retention import ( _human_bytes, + check_storage_health, compute_storage_summary, load_retention_config, purge, ) from think.streams import list_streams from think.utils import get_config as get_journal_config +from think.utils import get_journal logger = logging.getLogger(__name__) @@ -2155,6 +2157,8 @@ def get_storage() -> Any: try: summary = compute_storage_summary() config = load_retention_config() + journal_path = get_journal() + warnings = check_storage_health(summary, journal_path) try: streams = list_streams() except Exception: @@ -2180,6 +2184,7 @@ def get_storage() -> Any: }, }, "streams": [{"name": s.get("name", "")} for s in streams], + "warnings": warnings, } ) except Exception: diff --git a/apps/settings/workspace.html b/apps/settings/workspace.html index fdbbabed5..b4687d005 100644 --- a/apps/settings/workspace.html +++ b/apps/settings/workspace.html @@ -2621,6 +2621,7 @@ button:focus:not(:focus-visible) {

storage

Manage raw media retention and view storage usage.

+
@@ -5792,6 +5793,18 @@ async function loadStorage() { } function populateStorage(data) { + // Render storage warnings + const warningsEl = document.getElementById('storageWarnings'); + warningsEl.innerHTML = ''; + if (data.warnings && data.warnings.length > 0) { + data.warnings.forEach(w => { + const div = document.createElement('div'); + div.className = 'provider-key-warning'; + div.innerHTML = '⚠ ' + escapeHtml(w.message) + ''; + warningsEl.appendChild(div); + }); + } + const s = data.summary; document.getElementById('storageSummaryRaw').textContent = s.raw_media_human; document.getElementById('storageSummaryDerived').textContent = s.derived_human; @@ -6055,13 +6068,7 @@ async function runCleanupExecute() { closeCleanupModal(); - // Refresh storage summary - if (result.summary) { - storageData.summary = result.summary; - populateStorage(storageData); - } else { - loadStorage(); - } + loadStorage(); } catch (err) { notifyError('Cleanup Failed', err.message); } finally { diff --git a/tests/test_retention.py b/tests/test_retention.py index 5d6a745e2..716d94773 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -6,12 +6,15 @@ import hashlib import json import os +import shutil from datetime import datetime from think.retention import ( RetentionConfig, RetentionPolicy, + StorageSummary, _human_bytes, + check_storage_health, get_raw_media_files, is_raw_media, is_segment_complete, @@ -483,3 +486,160 @@ class TestHumanBytes: def test_large(self): result = _human_bytes(12_400_000_000) assert "GB" in result + + +class TestCheckStorageHealth: + """Tests for check_storage_health threshold evaluation.""" + + def _make_summary(self, raw_media_bytes=0, derived_bytes=0): + return StorageSummary( + raw_media_bytes=raw_media_bytes, + derived_bytes=derived_bytes, + total_segments=10, + segments_with_raw=5, + segments_purged=3, + ) + + def test_no_warnings_when_healthy(self, tmp_path, monkeypatch): + """No warnings when disk is below threshold and raw media GB is null.""" + usage_type = type(shutil.disk_usage(tmp_path)) + monkeypatch.setattr( + "shutil.disk_usage", + lambda path: usage_type(1000, 500, 500), # 50% used + ) + config = { + "retention": { + "storage_warning_disk_percent": 80, + "storage_warning_raw_media_gb": None, + } + } + summary = self._make_summary() + warnings = check_storage_health(summary, tmp_path, config=config) + assert warnings == [] + + def test_disk_percent_exceeded(self, tmp_path, monkeypatch): + """Warning when disk usage exceeds threshold.""" + config = { + "retention": { + "storage_warning_disk_percent": 1, + } + } + summary = self._make_summary() + warnings = check_storage_health(summary, tmp_path, config=config) + assert len(warnings) == 1 + assert warnings[0]["type"] == "disk_percent" + assert warnings[0]["level"] == "warning" + assert warnings[0]["current"] >= 1 + assert warnings[0]["threshold"] == 1 + assert "retention settings" in warnings[0]["message"] + assert "Clean Up Now" in warnings[0]["message"] + + def test_disk_percent_not_exceeded(self, tmp_path, monkeypatch): + """No warning when disk is well below threshold.""" + config = { + "retention": { + "storage_warning_disk_percent": 100, + } + } + summary = self._make_summary() + warnings = check_storage_health(summary, tmp_path, config=config) + assert warnings == [] + + def test_raw_media_gb_exceeded(self, tmp_path, monkeypatch): + """Warning when raw media exceeds GB threshold.""" + raw_bytes = int(5.5 * 1024**3) + config = { + "retention": { + "storage_warning_disk_percent": None, + "storage_warning_raw_media_gb": 5.0, + } + } + summary = self._make_summary(raw_media_bytes=raw_bytes) + warnings = check_storage_health(summary, tmp_path, config=config) + assert len(warnings) == 1 + assert warnings[0]["type"] == "raw_media_gb" + assert warnings[0]["level"] == "warning" + assert warnings[0]["current"] >= 5.0 + assert warnings[0]["threshold"] == 5.0 + assert "retention settings" in warnings[0]["message"] + + def test_raw_media_gb_not_exceeded(self, tmp_path, monkeypatch): + """No warning when raw media is below threshold.""" + raw_bytes = int(2.0 * 1024**3) + config = { + "retention": { + "storage_warning_disk_percent": None, + "storage_warning_raw_media_gb": 5.0, + } + } + summary = self._make_summary(raw_media_bytes=raw_bytes) + warnings = check_storage_health(summary, tmp_path, config=config) + assert warnings == [] + + def test_both_thresholds_exceeded(self, tmp_path, monkeypatch): + """Both warnings when both thresholds exceeded.""" + raw_bytes = int(10 * 1024**3) + config = { + "retention": { + "storage_warning_disk_percent": 1, + "storage_warning_raw_media_gb": 5.0, + } + } + summary = self._make_summary(raw_media_bytes=raw_bytes) + warnings = check_storage_health(summary, tmp_path, config=config) + assert len(warnings) == 2 + types = {w["type"] for w in warnings} + assert types == {"disk_percent", "raw_media_gb"} + + def test_null_thresholds_disables_checks(self, tmp_path, monkeypatch): + """Both thresholds null means no warnings ever.""" + raw_bytes = int(100 * 1024**3) + config = { + "retention": { + "storage_warning_disk_percent": None, + "storage_warning_raw_media_gb": None, + } + } + summary = self._make_summary(raw_media_bytes=raw_bytes) + warnings = check_storage_health(summary, tmp_path, config=config) + assert warnings == [] + + def test_exact_threshold_triggers(self, tmp_path, monkeypatch): + """Warning triggers at exactly the threshold (>=, not >).""" + raw_bytes = int(5.0 * 1024**3) + config = { + "retention": { + "storage_warning_disk_percent": None, + "storage_warning_raw_media_gb": 5.0, + } + } + summary = self._make_summary(raw_media_bytes=raw_bytes) + warnings = check_storage_health(summary, tmp_path, config=config) + assert len(warnings) == 1 + assert warnings[0]["type"] == "raw_media_gb" + + def test_missing_retention_section_uses_defaults(self, tmp_path, monkeypatch): + """Missing retention section falls back to defaults (80% disk, null raw media).""" + config = {} + summary = self._make_summary() + warnings = check_storage_health(summary, tmp_path, config=config) + for w in warnings: + assert w["type"] != "raw_media_gb" + + def test_warning_dict_structure(self, tmp_path, monkeypatch): + """Each warning has all required keys.""" + config = { + "retention": { + "storage_warning_disk_percent": 1, + "storage_warning_raw_media_gb": 0.001, + } + } + raw_bytes = int(1 * 1024**3) + summary = self._make_summary(raw_media_bytes=raw_bytes) + warnings = check_storage_health(summary, tmp_path, config=config) + for w in warnings: + assert "level" in w + assert "type" in w + assert "message" in w + assert "current" in w + assert "threshold" in w diff --git a/think/dream.py b/think/dream.py index 55b0d06e4..2a613991c 100644 --- a/think/dream.py +++ b/think/dream.py @@ -2504,6 +2504,36 @@ def main() -> None: stats_cmd.append("--verbose") run_command(stats_cmd, day) + # Check storage health and emit warnings + try: + from think.retention import check_storage_health, compute_storage_summary + from think.callosum import callosum_send + + storage_summary = compute_storage_summary() + journal_path = get_journal() + storage_warnings = check_storage_health(storage_summary, journal_path) + for warning in storage_warnings: + callosum_send( + "storage", + "warning", + level=warning["level"], + type=warning["type"], + message=warning["message"], + current=warning["current"], + threshold=warning["threshold"], + ) + if storage_warnings: + callosum_send( + "notification", + "show", + title="Storage Warning", + message=storage_warnings[0]["message"], + icon="💾", + action="/app/settings#storage", + ) + except Exception: + logging.debug("Storage health check failed in post-phase", exc_info=True) + # Touch daily.updated marker after daily schedule completion try: health_dir = day_path(day) / "health" diff --git a/think/journal_default.json b/think/journal_default.json index 83723f697..ee5942684 100644 --- a/think/journal_default.json +++ b/think/journal_default.json @@ -35,6 +35,8 @@ "retention": { "raw_media": "keep", "raw_media_days": null, - "per_stream": {} + "per_stream": {}, + "storage_warning_disk_percent": 80, + "storage_warning_raw_media_gb": null } } diff --git a/think/retention.py b/think/retention.py index ce6295a27..b8364d4ca 100644 --- a/think/retention.py +++ b/think/retention.py @@ -18,6 +18,7 @@ from __future__ import annotations import hashlib import json import logging +import shutil from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -249,6 +250,80 @@ def compute_storage_summary() -> StorageSummary: return summary +def check_storage_health( + summary: StorageSummary, + journal_path: str | Path, + config: dict | None = None, +) -> list[dict]: + """Check storage health against configured thresholds. + + Parameters + ---------- + summary + Pre-computed storage summary (avoids recomputation). + journal_path + Journal root path, used for disk usage check. + config + Full journal config dict. Loaded via get_config() if not provided. + + Returns + ------- + list[dict] + List of warning dicts. Empty when healthy. + """ + if config is None: + from think.utils import get_config + + config = get_config() + + retention = config.get("retention", {}) + warnings = [] + + # Check disk usage percentage + disk_threshold = retention.get("storage_warning_disk_percent", 80) + if disk_threshold is not None: + try: + usage = shutil.disk_usage(str(journal_path)) + disk_percent = round(usage.used / usage.total * 100, 1) + if disk_percent >= disk_threshold: + warnings.append( + { + "level": "warning", + "type": "disk_percent", + "message": ( + f"Disk is {disk_percent}% full (threshold: {disk_threshold}%). " + "Consider adjusting retention settings or running Clean Up Now " + "to free space." + ), + "current": disk_percent, + "threshold": disk_threshold, + } + ) + except OSError: + pass + + # Check raw media size + raw_media_gb_threshold = retention.get("storage_warning_raw_media_gb") + if raw_media_gb_threshold is not None: + raw_media_gb = round(summary.raw_media_bytes / (1024**3), 2) + if raw_media_gb >= raw_media_gb_threshold: + warnings.append( + { + "level": "warning", + "type": "raw_media_gb", + "message": ( + f"Raw media is {raw_media_gb} GB (threshold: {raw_media_gb_threshold} GB). " + "Consider adjusting retention settings or running Clean Up Now " + "to free space." + ), + "current": raw_media_gb, + "threshold": raw_media_gb_threshold, + } + ) + + return warnings + + # --------------------------------------------------------------------------- # Retention purge # --------------------------------------------------------------------------- diff --git a/think/tools/call.py b/think/tools/call.py index 068dc1318..523f56557 100644 --- a/think/tools/call.py +++ b/think/tools/call.py @@ -953,12 +953,52 @@ def purge( @app.command(name="storage-summary") def storage_summary( json_output: bool = typer.Option(False, "--json", help="Output as JSON."), + check: bool = typer.Option( + False, "--check", help="Check storage health thresholds." + ), ) -> None: """Show journal storage summary.""" from think.retention import compute_storage_summary summary = compute_storage_summary() + if check: + from think.retention import check_storage_health + from think.utils import get_journal + + journal_path = get_journal() + warnings = check_storage_health(summary, journal_path) + + if json_output: + typer.echo( + json.dumps( + { + "raw_media_bytes": summary.raw_media_bytes, + "derived_bytes": summary.derived_bytes, + "total_segments": summary.total_segments, + "segments_with_raw": summary.segments_with_raw, + "segments_purged": summary.segments_purged, + "warnings": warnings, + }, + indent=2, + ) + ) + else: + typer.echo(f"Raw media: {summary.raw_media_human}") + typer.echo(f"AI-processed content: {summary.derived_human}") + typer.echo( + f"Segments: {summary.total_segments} total, " + f"{summary.segments_with_raw} with raw media, " + f"{summary.segments_purged} purged" + ) + if warnings: + typer.echo("") + for w in warnings: + typer.echo(f"âš  {w['message']}") + else: + typer.echo("\nAll storage thresholds OK.") + return + if json_output: typer.echo( json.dumps(