Restructures the disk-cleanup command to show location, current on-disk size, and an estimated reclaim per area (attachments, project files, container workspaces) before touching anything - --dry-run stops there. A real run re-executes each check, reports actual items/bytes freed, an "After" size per area, and a final total with elapsed time, flagging any drift between the estimate and execution passes (e.g. the live server wrote something in between). store.gc_workspaces() now reports bytes freed alongside the removed count (previously count-only), so the containers gc-workspaces CLI action picks up the same reporting for free. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5tFWkm3UbcstcbPKFE5gP
203 lines
6.1 KiB
Python
203 lines
6.1 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from devplacepy.cli._shared import _audit_cli
|
|
|
|
RULE_WIDTH = 72
|
|
|
|
|
|
def _rule(char: str = "=") -> str:
|
|
return char * RULE_WIDTH
|
|
|
|
|
|
def _human(n) -> str:
|
|
n = float(n)
|
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
if n < 1024:
|
|
return f"{n:.1f}{unit}"
|
|
n /= 1024
|
|
return f"{n:.1f}PB"
|
|
|
|
|
|
def _dir_stats(path) -> tuple[int, int]:
|
|
import os
|
|
|
|
count = 0
|
|
total = 0
|
|
for root, _dirs, files in os.walk(str(path)):
|
|
for name in files:
|
|
count += 1
|
|
try:
|
|
total += os.path.getsize(os.path.join(root, name))
|
|
except OSError:
|
|
continue
|
|
return count, total
|
|
|
|
|
|
def _section(title: str) -> None:
|
|
print()
|
|
print(_rule())
|
|
print(title)
|
|
print(_rule())
|
|
|
|
|
|
def _build_areas():
|
|
from pathlib import Path
|
|
|
|
from devplacepy import config
|
|
from devplacepy.attachments import (
|
|
ATTACHMENTS_DIR,
|
|
purge_soft_deleted_attachments,
|
|
sweep_orphan_attachment_blobs,
|
|
)
|
|
from devplacepy.project_files import (
|
|
PROJECT_FILES_DIR,
|
|
purge_soft_deleted_project_files,
|
|
sweep_orphan_project_file_blobs,
|
|
)
|
|
from devplacepy.services.containers import store as container_store
|
|
|
|
return [
|
|
{
|
|
"label": "Attachments",
|
|
"path": ATTACHMENTS_DIR,
|
|
"checks": [
|
|
("soft-deleted attachments", purge_soft_deleted_attachments),
|
|
(
|
|
"orphan attachment blobs (zero DB reference)",
|
|
sweep_orphan_attachment_blobs,
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"label": "Project files",
|
|
"path": PROJECT_FILES_DIR,
|
|
"checks": [
|
|
("soft-deleted project files", purge_soft_deleted_project_files),
|
|
(
|
|
"orphan project-file blobs (zero DB reference)",
|
|
sweep_orphan_project_file_blobs,
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"label": "Container workspaces",
|
|
"path": Path(config.CONTAINER_WORKSPACES_DIR),
|
|
"checks": [
|
|
(
|
|
"orphaned workspace directories (no live instance)",
|
|
container_store.gc_workspaces,
|
|
),
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
def cmd_system_prune(args):
|
|
import time
|
|
|
|
dry_run = bool(args.dry_run)
|
|
areas = _build_areas()
|
|
|
|
print("System prune - safe but aggressive disk-space cleanup")
|
|
print(
|
|
"Removes only content with zero live reference: soft-deleted attachment/"
|
|
"\nproject-file blobs, blob files with no matching database row at all"
|
|
"\n(orphans, e.g. left by an interrupted or racing sync), and orphaned"
|
|
"\ncontainer workspace directories. Never touches live content."
|
|
)
|
|
if dry_run:
|
|
print("DRY RUN: this pass only reports; nothing will be deleted.")
|
|
|
|
started = time.monotonic()
|
|
|
|
_section("Where the data is, and what can be reclaimed (estimate)")
|
|
estimate_items = 0
|
|
estimate_bytes = 0
|
|
for area in areas:
|
|
count, size = _dir_stats(area["path"])
|
|
print(f"\n{area['label']}")
|
|
print(f" location: {area['path']}")
|
|
print(f" currently on disk: {count} file(s), {_human(size)}")
|
|
for check_label, fn in area["checks"]:
|
|
c, f = fn(dry_run=True)
|
|
estimate_items += c
|
|
estimate_bytes += f
|
|
print(f" expected to reclaim - {check_label}: {c} item(s), {_human(f)}")
|
|
|
|
print()
|
|
print(_rule("-"))
|
|
print(
|
|
f"Estimated total: {estimate_items} item(s), {_human(estimate_bytes)} reclaimable"
|
|
)
|
|
print(_rule("-"))
|
|
|
|
if dry_run:
|
|
print()
|
|
print("DRY RUN complete: nothing was deleted. Re-run without --dry-run to apply.")
|
|
return
|
|
|
|
_section("Executing")
|
|
actual_items = 0
|
|
actual_bytes = 0
|
|
for area in areas:
|
|
print(f"\n{area['label']} - {area['path']}")
|
|
for check_label, fn in area["checks"]:
|
|
c, f = fn(dry_run=False)
|
|
actual_items += c
|
|
actual_bytes += f
|
|
print(f" removed - {check_label}: {c} item(s), {_human(f)}")
|
|
|
|
_section("After")
|
|
for area in areas:
|
|
count, size = _dir_stats(area["path"])
|
|
print(f" {area['label']} ({area['path']}): {count} file(s), {_human(size)}")
|
|
|
|
elapsed = time.monotonic() - started
|
|
|
|
print()
|
|
print(_rule())
|
|
print(
|
|
f"Removed {actual_items} item(s) total, freed {_human(actual_bytes)} "
|
|
f"in {elapsed:.1f}s"
|
|
)
|
|
if estimate_bytes != actual_bytes or estimate_items != actual_items:
|
|
print(
|
|
f"(Estimate was {estimate_items} item(s), {_human(estimate_bytes)} - "
|
|
"state changed between the estimate and execution passes, e.g. the "
|
|
"live server wrote or soft-deleted something in between.)"
|
|
)
|
|
print(_rule())
|
|
|
|
_audit_cli(
|
|
"cli.system.prune",
|
|
f"CLI system prune removed {actual_items} item(s), freed {_human(actual_bytes)}",
|
|
metadata={
|
|
"items": actual_items,
|
|
"bytes_freed": actual_bytes,
|
|
"estimated_items": estimate_items,
|
|
"estimated_bytes": estimate_bytes,
|
|
"elapsed_seconds": round(elapsed, 3),
|
|
},
|
|
)
|
|
|
|
|
|
def register_system(subparsers):
|
|
system = subparsers.add_parser("system", help="Cross-cutting system maintenance")
|
|
system_sub = system.add_subparsers(title="action", dest="action")
|
|
prune = system_sub.add_parser(
|
|
"prune",
|
|
help=(
|
|
"Safe but aggressive disk-space cleanup: purges soft-deleted "
|
|
"attachment/project-file blobs and any blob with zero database "
|
|
"reference at all, plus orphaned container workspace directories. "
|
|
"Reports location, current size, and estimated reclaim per area "
|
|
"before acting, then confirms what was actually freed."
|
|
),
|
|
)
|
|
prune.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Report location, size, and estimated reclaim without deleting anything",
|
|
)
|
|
prune.set_defaults(func=cmd_system_prune)
|