"""Maintenance routes: reviewed, read-only plans (#249 System S6). ``POST /prompt_manager/maintenance/preview {operation, options:{}}`` reviews one operation (``duplicate_prompts``, ``optimize``, ``unused_components`` or ``missing_links``; ``database/maintenance.py``) off the event loop and answers a ``Plan``: ``{success, token, operation, expires_at, scope:{count, label}, protected:{count, reasons}, backup:{policy, destination_label}, media:{deletes_files, label}, details:[≤50 {label, detail}]}``. The preview writes nothing. The plan's fingerprint, its targets and the sources it judged stay in the server's ``PlanStore`` under the token, bound to the database the preview read (a restore that replaces it invalidates the plan). The operation enum is strict, ``options`` must be an empty object and the body is capped at ``PREVIEW_BODY_MAX`` bytes. Errors are ``{success:false, code, error}`` with ``invalid-request`` (400), ``too-large`` (413: the body, or a plan over the store's budget), ``storage-changed`` (409: the database was replaced while the preview read it) or a null code (500); none carries a server path or exception text. ``POST /prompt_manager/maintenance/apply {token}`` applies a reviewed plan as an Activity job (202 ``{job}``): kind ``maintenance``, titled after the operation, admission ``records`` (refused while a scan, restore, LoRA import or other record-changing job runs, and refusing them meanwhile), not cancellable, with outcome groups ``changed``, ``protected`` and ``errors``. The token is consumed when the job starts (on the same loop turn, before its runner runs); a refused start keeps it. The runner: 1. takes a safety backup when the plan was reviewed with the policy on or the policy (``maintenance.safety_backup``, default on) is on now: SQLite's backup API into a temp file beside the database, ``verify_database_file``, fsync, ``0o600`` on POSIX, then ``os.replace`` onto a unique ``.backup_[_n]`` name. Any failure removes what it wrote and ends the job ``backup-failed`` with nothing changed; 2. applies the plan, which first rechecks the reviewed fingerprint inside ``BEGIN IMMEDIATE`` (``plan-changed`` writes nothing); 3. counts the changed and protected records, and prunes old safety backups. The job log names the backup by its file name only, and the job says "No safety backup" when none was taken. Apply errors: ``invalid-request`` (400), ``apply-failed`` (500: nothing started), ``plan-expired`` (410: unknown, expired or forgotten after a restart), ``plan-used`` (409, with the job that applied it), ``storage-changed`` (409: a restore replaced the database since the review) and ``busy`` (409). """ import datetime import json import os import shutil import sqlite3 import tempfile from contextlib import closing from aiohttp import web from .admin import _prune_safety_backups, _scan_location, _scan_roots from .job_routes import busy_response from .jobs import MAINTENANCE_JOB_KIND, JobBusy from .maintenance_plans import PlanExpired, PlanUsed, ScopeTooLarge try: from ...database.workflow_storage import PRE_MIGRATION_LABEL, pre_migration_backups from ...database.health import readonly_uri from ...database.maintenance import MAINTENANCE_OPERATIONS, PlanChanged except ImportError: from database.workflow_storage import PRE_MIGRATION_LABEL, pre_migration_backups from database.health import readonly_uri from database.maintenance import MAINTENANCE_OPERATIONS, PlanChanged PREVIEW_BODY_MAX = 4096 # Duplicate-file previews carry the reviewed keepers (py/api/duplicate_files.py). DUPLICATE_PREVIEW_BODY_MAX = 1024 * 1024 DUPLICATE_FILES = "duplicate_files" WORKFLOW_STORAGE = "workflow_storage" # applied by py/api/workflow_storage.py PREVIEW_OPERATIONS = MAINTENANCE_OPERATIONS + (DUPLICATE_FILES,) # Room left over beyond what a step writes, so a disk is never filled to 0. DISK_MARGIN_BYTES = 16 * 2**20 # The concept's Maintenance row titles, which name each operation's job. MAINTENANCE_TITLES = { "duplicate_prompts": "Duplicate prompt text", "optimize": "Optimize database", "unused_components": "Unused prompt components", "missing_links": "Missing-file links", "workflow_storage": "Store each workflow once", } _OPERATION_ERROR = "operation must be one of: " + ", ".join(PREVIEW_OPERATIONS) MAINTENANCE_GROUPS = ("changed", "protected", "errors") MAX_TOKEN_LENGTH = 128 BACKUP_FAILED = ( "The safety backup could not be taken and verified; nothing was changed." ) PLAN_CHANGED = ( "The library changed since the review; nothing was changed. " "Review the plan again." ) APPLY_FAILED = "Maintenance failed; nothing was changed." NOTHING_TO_DO = "Every image record already stores its workflow once; nothing to do." NO_BACKUP = "No safety backup" _NAME_ATTEMPTS = 1000 class SafetyBackupFailed(Exception): """The safety backup could not be written, verified or put in place. ``reason`` is the verify reason when the copy did not verify. """ def __init__(self, message, reason=None): super().__init__(message) self.reason = reason def backup_failed_message(error): """The job line for a failed backup, naming a damaged workflow document.""" reason = getattr(error, "reason", None) or "" if reason.startswith("Workflow document "): return f"{BACKUP_FAILED} {reason}" return BACKUP_FAILED def _now(): return datetime.datetime.now() def _free_bytes(path): return shutil.disk_usage(path).free def _device(path): try: return os.stat(path).st_dev except OSError: return path def sqlite_temp_dir(environ=None, *, windows=None, usable=None): """The folder SQLite builds its temporary files in (VACUUM's copy). Mirrors SQLite's own search, which differs from ``tempfile``'s. On Unix (``unixTempFileDir``): ``SQLITE_TMPDIR``, ``TMPDIR``, then ``/var/tmp``, ``/usr/tmp`` and ``/tmp``, the first that is a writable directory, else the current directory. On Windows (``GetTempPathW``): ``TMP``, ``TEMP``, then ``USERPROFILE``, else the current directory. """ environ = os.environ if environ is None else environ if windows is None: windows = os.name == "nt" if windows: for name in ("TMP", "TEMP", "USERPROFILE"): if environ.get(name): return environ[name] return os.path.abspath(".") if usable is None: def usable(path): return os.path.isdir(path) and os.access(path, os.W_OK | os.X_OK) candidates = [environ.get("SQLITE_TMPDIR"), environ.get("TMPDIR")] candidates += ["/var/tmp", "/usr/tmp", "/tmp"] for candidate in candidates: if candidate and usable(candidate): return candidate return os.path.abspath(".") def database_bytes(db_path): """The size a safety backup of the database will be (blocking). SQLite's backup API copies every page, committed WAL content included, so the copy is ``page_count * page_size`` read on one read-only snapshot. """ conn = sqlite3.connect(readonly_uri(db_path), uri=True) try: page_size = conn.execute("PRAGMA page_size").fetchone()[0] return page_size * conn.execute("PRAGMA page_count").fetchone()[0] finally: conn.close() def _megabytes(size): return f"{size / 2**20:,.1f} MB" def disk_plan(db_path, *, backup, vacuum, vacuum_bytes=None): """Whether the disk has room for an apply's writes (blocking). ``backup``: the safety backup, one database-sized file beside the database. ``vacuum``: VACUUM writes the rebuilt database through the WAL beside it and builds it in a temporary copy in SQLite's temporary folder (``sqlite_temp_dir``; SQLite's documentation: up to twice the database in free space). Needs on one device add up. ``vacuum_bytes``, when given, is what VACUUM copies (the live pages, for Reclaim space) instead of the whole file. Returns ``(public, short)``: ``public`` is ``{free_bytes, required_bytes, sufficient}`` for the database folder, ``short`` None or ``(place, what, required, free)`` of the first place that is short, and ``size`` the database's size (0 when nothing is written). """ db_path = os.path.abspath(db_path) folder = os.path.dirname(db_path) size = database_bytes(db_path) if backup or vacuum else 0 needs = [] # (place, folder, bytes, what) if backup: needs.append(("database folder", folder, size, "the safety backup")) if vacuum: copy = size if vacuum_bytes is None else vacuum_bytes what = "Optimize" if vacuum_bytes is None else "Reclaim space" needs.append(("database folder", folder, copy, what)) needs.append(("temporary folder", sqlite_temp_dir(), copy, what)) devices = {} for place, path, needed, what in needs: entry = devices.setdefault(_device(path), {"place": place, "path": path}) entry["bytes"] = entry.get("bytes", 0) + needed entry.setdefault("what", []) if what not in entry["what"]: entry["what"].append(what) short = None public = {"free_bytes": _free_bytes(folder), "required_bytes": 0} for device, entry in devices.items(): required = entry["bytes"] + DISK_MARGIN_BYTES free = ( public["free_bytes"] if entry["path"] == folder else _free_bytes(entry["path"]) ) if device == _device(folder): public["required_bytes"] = required if free < required and short is None: short = (entry["place"], " and ".join(entry["what"]), required, free) public["sufficient"] = short is None return public, short, size # A restore upload's hard ceiling; below it, free disk space decides. RESTORE_MAX_BYTES = 64 * 2**30 def size_label(size): """``1.6 GB`` or ``120.0 MB``: a backup's size for a message.""" if size >= 2**30: return f"{size / 2**30:,.1f} GB" return _megabytes(size) def restore_room(db_path): """The largest restore upload the disks have room for (blocking). The upload is written to a temp file and staged once more in the temporary folder (2x), then copied over the live database beside it, whose current contents are first kept as a safety backup (1x the upload for the WAL, plus the current database). Needs on one device add up, each with ``DISK_MARGIN_BYTES``. """ db_path = os.path.abspath(db_path) folder = os.path.dirname(db_path) current = database_bytes(db_path) if os.path.exists(db_path) else 0 devices = {} for path, per_upload, fixed in ( (tempfile.gettempdir(), 2, 0), (folder, 1, current), ): entry = devices.setdefault(_device(path), {"path": path, "per": 0, "fixed": 0}) entry["per"] += per_upload entry["fixed"] += fixed room = min( (_free_bytes(e["path"]) - e["fixed"] - DISK_MARGIN_BYTES) // e["per"] for e in devices.values() ) return max(0, room) def restore_space_message(size, room, *, known): """The truthful refusal of a restore the disks have no room for.""" if known: return ( f"Not enough free disk space to restore a {size_label(size)} backup; " f"there is room for about {size_label(room)}. Nothing was changed." ) return ( "Not enough free disk space to restore this backup: it is larger than " f"the {size_label(room)} there is room for. Nothing was changed." ) def short_space_message(short): """The truthful refusal for ``disk_plan``'s ``short``.""" place, what, required, free = short verb = "need" if " and " in what else "needs" return ( f"Not enough free disk space: {what} {verb} about {_megabytes(required)} " f"in the {place} and {_megabytes(free)} is free. Nothing was changed." ) def _changed_line(operation, count): if operation == "optimize": return "Optimized the database" if operation == "duplicate_prompts": noun = "duplicate prompt" if count == 1 else "duplicate prompts" return f"Merged {count:,} {noun}" if operation == "unused_components": noun = "unused prompt component" if count == 1 else "unused prompt components" return f"Removed {count:,} {noun}" noun = "link to a missing file" if count == 1 else "links to missing files" return f"Removed {count:,} {noun}" def _remove_quietly(*paths): for path in paths: try: if path and os.path.exists(path): os.remove(path) except OSError: pass def _reserve_backup_name(db_path, label="backup"): """Create and return an unused ``.