#!/usr/bin/env python3 """Inspect retention candidates and optionally clean verified managed jobs. Default mode is a safe dry-run. Metadata-less workspaces are reported for manual investigation but are never removed by ``--apply``. """ from __future__ import annotations import argparse import json import os from datetime import datetime, timezone from pathlib import Path from .cleanup_service import CleanupService from .metadata_store import MetadataStore from .product_archive_store import ProductArchiveStore from .storage import Storage BACKEND_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = BACKEND_ROOT WORKSPACE_DIR = PROJECT_ROOT / "service_workspace" ASSETS_DIR = PROJECT_ROOT / "service_assets" METADATA_DIR = PROJECT_ROOT / "service_metadata" PRODUCTS_DIR = PROJECT_ROOT / "service_products" def read_asset_meta(path: Path) -> dict: try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return {} def referenced_job_ids() -> set[str]: refs: set[str] = set() for meta_path in ASSETS_DIR.glob("*/*/meta.json"): job_id = read_asset_meta(meta_path).get("job_id") or "" if job_id: refs.add(job_id) return refs def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--max-age-days", type=float, default=30) parser.add_argument( "--apply", action="store_true", help="Delete only CleanupService-verified candidates; orphan workspaces remain report-only", ) parser.add_argument("--json", type=Path, default=None, help="Write JSON report") args = parser.parse_args() store = MetadataStore(METADATA_DIR / "app.db") storage = Storage(WORKSPACE_DIR) product_store = ProductArchiveStore(PRODUCTS_DIR / "metadata") cleanup = CleanupService(storage, store, product_store) known_job_ids = store.job_ids() if store.db_path.exists() else set() asset_referenced = referenced_job_ids() archive_protected = cleanup.archive_protected_job_ids() orphaned = storage.stale_job_dirs( referenced_job_ids=archive_protected, exclude_job_ids=known_job_ids, max_age_days=args.max_age_days, ) cleanup_report = cleanup.preview(datetime.now(timezone.utc)) reclaimable = cleanup_report.reclaimable_bytes + sum(item["size_bytes"] for item in orphaned) report = { "scanned_at": datetime.now(timezone.utc).isoformat(), "job_dir_count": len(storage.list_job_ids()), "referenced_job_ids": len(asset_referenced), "asset_referenced_job_ids": sorted(asset_referenced), "archive_protected_job_ids": sorted(archive_protected), "known_job_ids": len(known_job_ids), "temporary_jobs": [item.model_dump(mode="json") for item in cleanup_report.temporary_jobs], "pending_product_ids": [item.product_id for item in cleanup_report.pending_products], "reminder_job_ids": cleanup_report.reminder_job_ids, "stale_job_count": len(orphaned), "reclaimable_bytes": reclaimable, "max_age_days": args.max_age_days, "apply": args.apply, "stale_jobs": orphaned[:200], } print(json.dumps(report, ensure_ascii=False, indent=2)) if args.json: args.json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") if args.apply: if os.environ.get("CLEANUP_APPLY_ENABLED", "false").strip().lower() != "true": parser.error("--apply requires CLEANUP_APPLY_ENABLED=true") applied = cleanup.apply(datetime.now(timezone.utc)) freed = sum( item.size_bytes for item in applied.temporary_jobs if item.job_id in applied.deleted_job_ids ) print( "freed_bytes=" f"{freed} deleted_job_ids={applied.deleted_job_ids} " f"deleted_product_ids={applied.deleted_product_ids}" ) if __name__ == "__main__": main()