feat(maintenance): apply reviewed plans as Activity jobs

POST /prompt_manager/maintenance/apply {token} starts a maintenance job
(records admission, not cancellable, groups changed/protected/errors).
The token is consumed on the loop turn the job starts: a second apply is
409 plan-used naming the job, a busy refusal keeps the token, an unknown
or expired one is 410 plan-expired, and a restore since the review is
409 storage-changed.

With the safety backup policy on (maintenance.safety_backup, default true,
saved through the settings route with the revision), the job first writes
a backup into a temp file beside the database, makes it a standalone
rollback-journal file, verifies it, fsyncs it and renames it onto a unique
<db>.backup_<YYYYmmdd_HHMMSS_ffffff>[_n] name; any failure leaves no file
and nothing changed. The apply then rechecks the reviewed fingerprint
inside BEGIN IMMEDIATE (plan-changed writes nothing), deletes in batches of
400 ids, counts changed and protected records, logs the backup by name
only and prunes old safety backups. With the policy off the log and the
status line say "No safety backup".
This commit is contained in:
Vito Sansevero
2026-10-23 17:25:53 -07:00
parent f09d2250df
commit 7df509e6fb
7 changed files with 964 additions and 7 deletions
+39
View File
@@ -2499,6 +2499,7 @@ node --test tests/js/workspace-system-state.test.js tests/js/workspace-system-co
tests/js/workspace-inspection-state.test.js tests/js/workspace-images-state.test.js \
tests/js/workspace-system-sources.test.js tests/js/workspace-thumbnail-review.test.js
python3 -m unittest tests.test_settings_sources tests.test_scan_scope -v
python3 -m unittest tests.test_maintenance_plans tests.test_api_maintenance -v
npm run --prefix tools/vendor test:workflows -- system-concept.spec.mjs workspace-system.spec.mjs \
workspace-system-sources.spec.mjs \
--project chromium --project touch --project firefox --project webkit
@@ -2531,6 +2532,44 @@ answers `storage-changed`. Errors are coded (`invalid-request`, `too-large`,
`storage-changed`, or a null code with "Failed to review maintenance") and carry no
path or exception text.
`POST /prompt_manager/maintenance/apply {token}` starts the reviewed plan as an
Activity job (202 `{job}`): kind `maintenance`, titled after the concept's row
("Duplicate prompt text", "Optimize database", "Unused prompt components", "Missing-file
links"), admission `records` (it never runs beside a scan, restore, LoRA import or other
record-changing job, legacy-page holds included: `test_apply_refused_while_restore_runs`),
not cancellable, with the groups `changed`, `protected` and `errors`. The token is consumed
on the loop turn the job starts, so a second apply answers 409 `plan-used` with that job;
a refused start (`busy`) keeps it. Other errors: `invalid-request`, `plan-expired` (410:
unknown, expired, or forgotten after a restart) and `storage-changed` (409: a restore
replaced the database since the review; the plan is dropped). The job:
1. takes a safety backup when the plan was reviewed with the policy on or the policy is
on now (`maintenance.safety_backup` in `config.json`, default `true`; GET/POST
`/prompt_manager/settings` field `safety_backup` with the revision). The copy is made
with SQLite's backup API into a temp file beside the database, switched to a
rollback journal (so a later read-only open leaves no `-wal`/`-shm` beside it),
verified with `verify_database_file`, fsynced, made `0o600` on POSIX and only then
renamed with `os.replace` onto `<db>.backup_<YYYYmmdd_HHMMSS_ffffff>`, reserved with
`O_EXCL` (`_1`, `_2`… when a coarse clock repeats a stamp:
`test_backup_names_unique_within_one_second`). A backup file on disk is therefore
always a verified one. Any failure removes what it wrote and ends the job
`backup-failed` before anything is changed (`test_backup_failure_aborts_before_write`);
2. applies the plan: inside `BEGIN IMMEDIATE` the fingerprint is computed again on the
write connection and a difference ends the job `plan-changed` with nothing written
(`test_apply_rejects_changed_target`; a missing link whose file is back or whose source
went away is never removed). The targets are deleted in batches of 400 ids in that one
transaction. Missing-file links remove database rows only; every media file's bytes
are unchanged (`test_missing_links_never_deletes_files`). VACUUM runs outside a
transaction and binds to its database only;
3. counts the changed and protected records (outcome rows hold root-relative paths or
prompt ids only), logs the backup by its file name, and prunes safety backups to the
newest 10. With no backup the log and the status line say "No safety backup".
A failure the job cannot name ends it "Maintenance failed; nothing was changed." with a
null code and no exception text. Windows: every connection the backup and the previews
open is closed before the temp copy is renamed or removed, and the suites' teardown
asserts no handle under the temp root is still open.
The legacy `POST /prompt_manager/maintenance` keeps its shape. Its `vacuum` result is
`success:false` with the error when VACUUM fails (`vacuum_database` now raises after
logging), and `cleanup_orphaned_images` removes only links under a source that can be
+82 -3
View File
@@ -50,6 +50,7 @@ MAINTENANCE_OPERATIONS = (
"missing_links",
)
MAX_DETAILS = 50 # detail rows a plan lists
MAX_RESULT_ROWS = 200 # changed records an apply describes (the job lists 200)
EXCERPT_CHARS = 80
# Ids bound per statement: the pair-reference read binds each id twice, which
# stays under SQLite's historical 999-variable limit.
@@ -220,9 +221,9 @@ def _add_prompt_state(fingerprint, conn, ids):
fingerprint.add(list(row))
def _texts(conn, ids):
"""``{id: text}`` of at most MAX_DETAILS prompts, for detail rows."""
ids = list(ids)[:MAX_DETAILS]
def _texts(conn, ids, limit=MAX_DETAILS):
"""``{id: text}`` of the first ``limit`` prompts, for detail rows."""
ids = list(ids)[:limit]
if not ids:
return {}
rows = conn.execute(
@@ -548,3 +549,81 @@ class MaintenanceOperations:
},
],
)
# ── apply ─────────────────────────────────────────────────────────
def apply_maintenance(self, operation, fingerprint, targets, roots=()) -> dict:
"""Apply a reviewed plan exactly, or raise PlanChanged (blocking).
Inside ``BEGIN IMMEDIATE`` the fingerprint is computed again on the
write connection; when it differs nothing is written. ``targets`` and
``roots`` are the plan's. Returns ``{changed, rows}``: how many
records changed and up to ``MAX_RESULT_ROWS`` descriptions of them
(``{prompt_id[, kept_id]}`` or ``{image_path}``).
"""
if operation not in MAINTENANCE_OPERATIONS:
raise ValueError(f"Unknown maintenance operation {operation!r}")
if operation == "optimize":
# VACUUM cannot run in a transaction; the plan binds the database.
if fingerprint != _Fingerprint("optimize").hexdigest():
raise PlanChanged("optimize")
self.model.vacuum_database()
return {"changed": 1, "rows": []}
appliers = {
"duplicate_prompts": self._apply_duplicate_prompts,
"unused_components": self._apply_unused_components,
"missing_links": self._apply_missing_links,
}
with self._pair_transaction() as conn:
return appliers[operation](conn, fingerprint, targets, roots)
def _apply_duplicate_prompts(self, conn, fingerprint, targets, _roots):
groups, _paired, current = self._duplicates_fingerprint(conn)
if current != fingerprint or groups != targets:
raise PlanChanged("duplicate_prompts")
changed, rows = 0, []
for sorted_ids in groups:
removed, _moved = self._merge_duplicate_group(conn, sorted_ids)
changed += removed
rows.extend(
{"prompt_id": duplicate_id, "kept_id": sorted_ids[0]}
for duplicate_id in sorted_ids[1:]
)
return {"changed": changed, "rows": rows[:MAX_RESULT_ROWS]}
def _apply_unused_components(self, conn, fingerprint, targets, _roots):
ids, current = self._unused_fingerprint(conn)
if current != fingerprint or ids != targets:
raise PlanChanged("unused_components")
texts = _texts(conn, ids, limit=MAX_RESULT_ROWS)
changed = _delete_ids(conn, "prompts", ids)
rows = [
{"prompt_id": prompt_id, "text": _excerpt(texts.get(prompt_id))}
for prompt_id in ids[:MAX_RESULT_ROWS]
]
return {"changed": changed, "rows": rows}
def _apply_missing_links(self, conn, fingerprint, targets, roots):
rows = []
for batch in _batches(sorted(targets)):
rows.extend(
tuple(row)
for row in conn.execute(
"SELECT id, image_path, filename, file_path FROM generated_images "
f"WHERE id IN ({_marks(batch)}) ORDER BY id", # nosec B608
batch,
)
)
readable = readable_roots(roots)
candidates = []
for row, owner, state in link_states(rows, roots, readable):
if state != "missing": # back, or its source went away
raise PlanChanged("missing_links")
candidates.append((row, owner))
if self._links_fingerprint(candidates) != fingerprint:
raise PlanChanged("missing_links")
changed = _delete_ids(conn, "generated_images", [row[0] for row in rows])
return {
"changed": changed,
"rows": [{"image_path": row[1]} for row in rows[:MAX_RESULT_ROWS]],
}
+286 -4
View File
@@ -16,19 +16,49 @@ 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
``<db>.backup_<YYYYmmdd_HHMMSS_ffffff>[_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),
``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 os
import sqlite3
import tempfile
from contextlib import closing
from aiohttp import web
from .maintenance_plans import ScopeTooLarge
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.maintenance import MAINTENANCE_OPERATIONS
from ...database.maintenance import MAINTENANCE_OPERATIONS, PlanChanged
except ImportError:
from database.maintenance import MAINTENANCE_OPERATIONS
from database.maintenance import MAINTENANCE_OPERATIONS, PlanChanged
PREVIEW_BODY_MAX = 4096
# The concept's Maintenance row titles, which name each operation's job.
@@ -39,6 +69,117 @@ MAINTENANCE_TITLES = {
"missing_links": "Missing-file links",
}
_OPERATION_ERROR = "operation must be one of: " + ", ".join(MAINTENANCE_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."
NO_BACKUP = "No safety backup"
_NAME_ATTEMPTS = 1000
class SafetyBackupFailed(Exception):
"""The safety backup could not be written, verified or put in place."""
def _now():
return datetime.datetime.now()
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):
"""Create and return an unused ``<db>.backup_<stamp>[_n]`` (O_EXCL)."""
base = f"{db_path}.backup_{_now().strftime('%Y%m%d_%H%M%S_%f')}"
for attempt in range(_NAME_ATTEMPTS):
candidate = base if attempt == 0 else f"{base}_{attempt}"
try:
fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
continue
os.close(fd)
return candidate
raise SafetyBackupFailed("No free backup name")
def _standalone(path):
"""Make the copy one self-contained file (rollback journal, not WAL).
The backup API copies the live database's WAL flag; a WAL file opened
read-only later (a verify, a restore preview) would leave ``-wal`` and
``-shm`` sidecars beside it.
"""
try:
with closing(sqlite3.connect(path)) as conn:
conn.execute("PRAGMA journal_mode=DELETE").fetchone()
except sqlite3.Error as exc:
raise SafetyBackupFailed("The backup could not be finished") from exc
def _fsync(path):
with open(path, "rb+") as handle:
os.fsync(handle.fileno())
def take_safety_backup(model):
"""Write and verify a safety backup; return its file name (blocking).
The copy is made in a temp file beside the database and only renamed
onto its final, unique name once it verified, so a backup file on disk
is always a verified one. Raises SafetyBackupFailed, leaving no file.
"""
db_path = os.path.abspath(model.db_path)
fd, temp = tempfile.mkstemp(
dir=os.path.dirname(db_path), prefix=".pm-safety-", suffix=".tmp"
)
os.close(fd)
final = None
try:
if not model.backup_database(temp):
raise SafetyBackupFailed("The backup could not be written")
_standalone(temp)
ok, _reason = model.verify_database_file(temp)
if not ok:
raise SafetyBackupFailed("The backup did not verify")
_fsync(temp)
if os.name != "nt":
os.chmod(temp, 0o600)
final = _reserve_backup_name(db_path)
os.replace(temp, final)
return os.path.basename(final)
except OSError as exc:
_remove_quietly(final)
raise SafetyBackupFailed("The backup could not be put in place") from exc
except SafetyBackupFailed:
_remove_quietly(final)
raise
finally:
_remove_quietly(temp, temp + "-journal", temp + "-wal", temp + "-shm")
def _failure(message, status, code=None, **extra):
@@ -63,6 +204,10 @@ class MaintenanceRoutesMixin:
async def maintenance_preview_route(request):
return await self.preview_maintenance(request)
@routes.post("/prompt_manager/maintenance/apply")
async def maintenance_apply_route(request):
return await self.apply_maintenance_plan(request)
def _database_identity(self, db):
"""What binds a plan to one database: the swap generation and its file."""
path = os.path.normcase(os.path.realpath(db.model.db_path))
@@ -71,7 +216,9 @@ class MaintenanceRoutesMixin:
@staticmethod
def _safety_backup_policy():
"""``on`` or ``off``: whether an apply takes a safety backup first."""
return "on"
from ..config import PromptManagerConfig
return "on" if PromptManagerConfig.SAFETY_BACKUP else "off"
async def _read_maintenance_body(self, request, allowed):
"""``(data, None)`` for a capped JSON object with only ``allowed`` keys."""
@@ -150,3 +297,138 @@ class MaintenanceRoutesMixin:
},
headers={"Cache-Control": "no-store"},
)
# ── apply ─────────────────────────────────────────────────────────
async def apply_maintenance_plan(self, request):
data, error = await self._read_maintenance_body(request, ("token",))
if error is not None:
return error
token = data.get("token")
if not isinstance(token, str) or not 0 < len(token) <= MAX_TOKEN_LENGTH:
return _invalid("token must be the plan's token")
store = self._maintenance_plans
try:
plan = store.peek(token)
except PlanUsed as used:
job = self._jobs.get(used.job_id)
return _failure(
"This plan was already applied.",
409,
"plan-used",
job={"id": used.job_id, "title": job.title if job else None},
)
except PlanExpired:
return _failure(
"This review expired. Review the plan again.", 410, "plan-expired"
)
db = self.db
if plan.db_identity != self._database_identity(db):
store.discard(token)
return _failure(
"The database was replaced since the review. Review again.",
409,
"storage-changed",
)
try:
job = self._jobs.start(
MAINTENANCE_JOB_KIND,
MAINTENANCE_TITLES[plan.operation],
admission="records",
cancellable=False,
scope={"operation": plan.operation},
groups=MAINTENANCE_GROUPS,
runner=lambda job: self._run_maintenance_plan(job, db, plan),
)
except JobBusy as busy:
return busy_response(busy) # the token stays usable
# Same loop turn as the start: the runner has not run yet.
store.consume(token, plan.operation, job_id=job.id)
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def _run_maintenance_plan(self, job, db, plan):
payload = plan.payload
backup = None
if payload["backup"] == "on" or self._safety_backup_policy() == "on":
job.update(status_line="Taking a safety backup")
job.log_line("Taking a safety backup of the database")
try:
backup = await self._run_in_executor(take_safety_backup, db.model)
except SafetyBackupFailed:
self.logger.error("Safety backup failed", exc_info=True)
return _fail_job(job, "backup-failed", BACKUP_FAILED)
job.log_line(f"Safety backup {backup} verified")
else:
job.log_line("No safety backup (the policy is off)")
job.update(status_line=f"Applying: {job.title}")
try:
result = await self._run_in_executor(
db.apply_maintenance,
plan.operation,
plan.fingerprint,
payload["targets"],
payload["roots"],
)
except PlanChanged:
return _fail_job(job, "plan-changed", PLAN_CHANGED)
except Exception:
self.logger.exception(f"Maintenance {plan.operation} failed")
return _fail_job(job, None, APPLY_FAILED)
rows = await self._run_in_executor(
self._maintenance_rows, plan.operation, result["rows"]
)
job.record_counted("changed", result["changed"], rows)
job.record_counted("protected", payload["protected"])
line = _changed_line(plan.operation, result["changed"])
job.log_line(line)
if backup:
removed = await self._run_in_executor(
_prune_safety_backups, db.model.db_path
)
if removed:
job.log_line(f"Removed {len(removed)} older safety backups")
job.update(
progress=100,
status_line=f"{line} · "
+ (f"Safety backup {backup}" if backup else NO_BACKUP),
)
def _maintenance_rows(self, operation, described):
"""Outcome rows for the changed records (root-relative paths only)."""
if operation == "missing_links":
roots = _scan_roots(self._get_all_output_dirs())
rows = []
for item in described:
path, label, index = _scan_location(item["image_path"], roots)
rows.append(
{
"path": path,
"root": label,
"root_index": index,
"detail": "Link removed; the file was already missing",
}
)
return rows
if operation == "duplicate_prompts":
return [
{
"path": "",
"detail": (
f"Prompt {item['prompt_id']} merged into "
f"prompt {item['kept_id']}"
),
}
for item in described
]
return [
{"path": "", "detail": f"Removed: {item.get('text') or item['prompt_id']}"}
for item in described
]
def _fail_job(job, code, message):
"""End ``job`` failed with ``message`` (one error row) and its code."""
job.record("errors", {"path": "", "detail": message}, f"! {message}")
job.update(status_line=message)
job.publish({"type": "error", "code": code, "message": message})
job.finish("failed")
+6
View File
@@ -40,6 +40,7 @@ _SETTINGS_FILE_KEYS = {
"INFINITE_SCROLL": ("web_ui", "infinite_scroll"),
"WORKER_THREADS": ("performance", "worker_threads"),
"MONITORING_ENABLED": ("gallery", "monitoring", "enabled"),
"SAFETY_BACKUP": ("maintenance", "safety_backup"),
}
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
@@ -187,6 +188,7 @@ class SettingsRoutesMixin:
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
"worker_threads": PromptManagerConfig.WORKER_THREADS,
"safety_backup": PromptManagerConfig.SAFETY_BACKUP,
"cpu_count": PromptManagerConfig.max_worker_threads(),
"gallery_root_paths": root_paths,
"gallery_root_path": root_paths[0] if root_paths else "",
@@ -481,6 +483,10 @@ class SettingsRoutesMixin:
if not isinstance(data["monitoring_enabled"], bool):
raise ValueError("monitoring_enabled must be true or false")
updates["MONITORING_ENABLED"] = data["monitoring_enabled"]
if "safety_backup" in data:
if not isinstance(data["safety_backup"], bool):
raise ValueError("safety_backup must be true or false")
updates["SAFETY_BACKUP"] = data["safety_backup"]
if "worker_threads" in data:
updates["WORKER_THREADS"] = validate_worker_threads(
data["worker_threads"], PromptManagerConfig.max_worker_threads()
+11
View File
@@ -659,6 +659,10 @@ class PromptManagerConfig:
DEFAULT_WORKER_THREADS = max(1, (os.cpu_count() or 1) // 2)
WORKER_THREADS = DEFAULT_WORKER_THREADS
# Maintenance: a reviewed apply takes a verified safety backup of the
# database first (System › Backup & export › Recovery policy, #249 S6).
SAFETY_BACKUP = True
@classmethod
def max_worker_threads(cls) -> int:
"""Largest accepted worker-thread count: the detected core count."""
@@ -713,6 +717,7 @@ class PromptManagerConfig:
"auto_backup_interval": cls.AUTO_BACKUP_INTERVAL,
"worker_threads": cls.WORKER_THREADS,
},
"maintenance": {"safety_backup": cls.SAFETY_BACKUP},
"gallery": GalleryConfig.get_config(),
"integrations": IntegrationConfig.get_config(),
"autotag": AutotagConfig.get_config(),
@@ -848,6 +853,12 @@ class PromptManagerConfig:
performance["worker_threads"], cls.WORKER_THREADS
)
maintenance = new_config.get("maintenance", {})
if isinstance(maintenance, dict) and isinstance(
maintenance.get("safety_backup"), bool
):
cls.SAFETY_BACKUP = maintenance["safety_backup"]
# Update gallery config
if "gallery" in new_config:
GalleryConfig.update_config(new_config["gallery"])
+437
View File
@@ -6,8 +6,11 @@ exactly that plan. Errors are coded and never carry a server path or an
exception text.
"""
import asyncio
import datetime
import hashlib
import json
import re
import os
import sqlite3
import sys
@@ -229,6 +232,440 @@ class TestPreviewRoute(MaintenanceAPITestCase):
restarted.db.close_all()
APPLY = "/prompt_manager/maintenance/apply"
BACKUP_NAME = re.compile(r"^prompts\.db\.backup_\d{8}_\d{6}_\d{6}(_\d+)?$")
class ApplyTestCase(MaintenanceAPITestCase):
def backups(self):
return sorted(
name
for name in os.listdir(self.tmpdir)
if name.startswith("prompts.db.backup_")
)
def strays(self):
"""Files beside the database that are neither it nor a safety backup."""
known = {"prompts.db", "prompts.db-wal", "prompts.db-shm", "config.json"}
return sorted(
name
for name in os.listdir(self.tmpdir)
if os.path.isfile(os.path.join(self.tmpdir, name))
and name not in known
and not BACKUP_NAME.match(name)
)
async def apply(self, token, status=202):
return await self.post(APPLY, {"token": token}, status)
async def run_apply(self, operation):
plan = await self.preview(operation)
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
return plan, body["job"], job
class TestApplyRoute(ApplyTestCase):
async def test_apply_runs_as_a_records_job_with_a_verified_backup(self):
orphan = self.save("Orphan")
plan, summary, job = await self.run_apply("unused_components")
self.assertEqual(summary["kind"], "maintenance")
self.assertEqual(summary["title"], "Unused prompt components")
self.assertEqual(summary["admission"], "records")
self.assertFalse(summary["cancellable"])
self.assertEqual(
summary["counters"], {"changed": 0, "protected": 0, "errors": 0}
)
self.assertEqual(summary["scope"], {"operation": "unused_components"})
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(job.counters, {"changed": 1, "protected": 0, "errors": 0})
self.assertIsNone(self.api.db.get_prompt_by_id(orphan))
[backup] = self.backups()
self.assertRegex(backup, BACKUP_NAME)
ok, _reason = self.api.db.model.verify_database_file(
os.path.join(self.tmpdir, backup)
)
self.assertTrue(ok)
self.assertEqual(self.strays(), [])
self.assertIn(f"Safety backup {backup} verified", "\n".join(job.log))
self.assertEqual(
job.status_line,
f"Removed 1 unused prompt component · Safety backup {backup}",
)
self.assertEqual(job.outcomes["changed"][0]["detail"], "Removed: Orphan")
report = json.dumps(job.report())
self.assertNotIn(self.tmpdir, report)
if os.name == "nt": # pragma: no cover - native path spelling
self.assertNotIn(self.tmpdir.replace("\\", "/"), report)
async def test_apply_rejects_changed_target(self):
orphan, soon = self.save("Orphan"), self.save("Soon a recipe")
plan = await self.preview("unused_components")
with self.api.db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
(soon, "Recipe"),
)
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "failed")
self.assertEqual(job.last_event["code"], "plan-changed")
self.assertEqual(
job.status_line,
"The library changed since the review; nothing was changed. "
"Review the plan again.",
)
self.assertEqual(job.counters["errors"], 1)
self.assertIsNotNone(self.api.db.get_prompt_by_id(orphan))
self.assertIsNotNone(self.api.db.get_prompt_by_id(soon))
async def test_token_is_single_use(self):
self.save("Orphan")
plan, summary, job = await self.run_apply("unused_components")
again = await self.apply(plan["token"], 409)
self.assertEqual(again["code"], "plan-used")
self.assertEqual(
again["job"], {"id": summary["id"], "title": "Unused prompt components"}
)
kinds = [j.kind for j in self.api._jobs.list()]
self.assertEqual(kinds.count("maintenance"), 1)
async def test_apply_refused_while_restore_runs(self):
orphan = self.save("Orphan")
plan = await self.preview("unused_components")
async with self.api._jobs.hold(
"restore", "Database restore (admin page)", admission="records"
) as restore:
busy = await self.apply(plan["token"], 409)
self.assertEqual(busy["code"], "busy")
self.assertEqual(
busy["running"],
{"id": restore.id, "title": "Database restore (admin page)"},
)
self.assertIsNotNone(self.api.db.get_prompt_by_id(orphan))
# The refused apply kept the token: it applies once the restore ended.
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertIsNone(self.api.db.get_prompt_by_id(orphan))
async def test_backup_failure_aborts_before_write(self):
orphan = self.save("Orphan")
model = self.api.db.model
failures = {
"backup": patch.object(model, "backup_database", return_value=False),
"verify": patch.object(
model, "verify_database_file", return_value=(False, "broken")
),
}
for name, failure in failures.items():
with self.subTest(failure=name):
with failure, patch.object(self.api.db, "apply_maintenance") as applied:
_plan, _summary, job = await self.run_apply("unused_components")
applied.assert_not_called()
self.assertEqual(job.state, "failed")
self.assertEqual(job.last_event["code"], "backup-failed")
self.assertEqual(
job.status_line,
"The safety backup could not be taken and verified; "
"nothing was changed.",
)
self.assertIsNotNone(self.api.db.get_prompt_by_id(orphan))
self.assertEqual(self.backups(), [])
self.assertEqual(self.strays(), [])
async def test_policy_off_reports_no_backup(self):
from py.config import PromptManagerConfig
PromptManagerConfig.SAFETY_BACKUP = False
orphan = self.save("Orphan")
plan, _summary, job = await self.run_apply("unused_components")
self.assertEqual(plan["backup"]["policy"], "off")
self.assertEqual(job.state, "finished")
self.assertIn("No safety backup (the policy is off)", "\n".join(job.log))
self.assertEqual(
job.status_line, "Removed 1 unused prompt component · No safety backup"
)
self.assertEqual(self.backups(), [])
self.assertIsNone(self.api.db.get_prompt_by_id(orphan))
async def test_a_plan_reviewed_with_the_backup_on_keeps_that_promise(self):
from py.config import PromptManagerConfig
self.save("Orphan")
plan = await self.preview("unused_components")
PromptManagerConfig.SAFETY_BACKUP = False # turned off after the review
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(len(self.backups()), 1)
async def test_backup_verified_before_write(self):
self.save("Orphan")
model, db = self.api.db.model, self.api.db
order = []
real_backup, real_verify = model.backup_database, model.verify_database_file
real_apply = db.apply_maintenance
def backup(path):
order.append("backup")
return real_backup(path)
def verify(path, **kwargs):
order.append("verify")
return real_verify(path, **kwargs)
def apply(*args):
order.append("apply")
self.assertEqual(len(self.backups()), 1) # in place before the write
return real_apply(*args)
with (
patch.object(model, "backup_database", side_effect=backup),
patch.object(model, "verify_database_file", side_effect=verify),
patch.object(db, "apply_maintenance", side_effect=apply),
):
_plan, _summary, job = await self.run_apply("unused_components")
self.assertEqual(job.state, "finished")
self.assertEqual(order, ["backup", "verify", "apply"])
async def test_missing_links_never_deletes_files(self):
prompt = self.save("Links")
kept = self.image(prompt, "kept.png")
gone = self.image(prompt, "sub/gone.png", exists=False)
other = Path(self.tmpdir) / "elsewhere"
self.image(prompt, "x.png", folder=other)
media = {
str(path): _digest(path)
for path in Path(self.tmpdir).rglob("*")
if path.is_file() and "prompts.db" not in path.name
}
_plan, _summary, job = await self.run_apply("missing_links")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(job.counters["changed"], 1)
self.assertEqual(
job.outcomes["changed"][0],
{
"path": "sub/gone.png",
"root": "output",
"root_index": 0,
"detail": "Link removed; the file was already missing",
},
)
after = {
str(path): _digest(path)
for path in Path(self.tmpdir).rglob("*")
if path.is_file() and "prompts.db" not in path.name and str(path) in media
}
self.assertEqual(after, media)
self.assertIsNone(self.api.db.get_image_by_id(gone))
self.assertIsNotNone(self.api.db.get_image_by_id(kept))
async def test_duplicate_prompts_and_optimize_apply(self):
kept = self.save("A cat")
with self.api.db.model.get_connection() as conn:
copy = conn.execute(
"INSERT INTO prompts(text, created_at) VALUES('A CAT', '2999-01-01')"
).lastrowid
_plan, _summary, job = await self.run_apply("duplicate_prompts")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(job.status_line.split(" · ")[0], "Merged 1 duplicate prompt")
self.assertEqual(
job.outcomes["changed"][0]["detail"],
f"Prompt {copy} merged into prompt {kept}",
)
self.assertIsNone(self.api.db.get_prompt_by_id(copy))
_plan, _summary, job = await self.run_apply("optimize")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(job.status_line.split(" · ")[0], "Optimized the database")
async def test_protected_items_are_counted(self):
self.save("Orphan")
self.save("Kept", tags=["__protected__"])
_plan, _summary, job = await self.run_apply("unused_components")
self.assertEqual(job.counters, {"changed": 1, "protected": 1, "errors": 0})
async def test_apply_failure_changes_nothing_and_is_coded(self):
orphan = self.save("Orphan")
with patch.object(
self.api.db,
"apply_maintenance",
side_effect=sqlite3.OperationalError(f"locked {self.tmpdir}"),
):
with self.assertLogs("prompt_manager", "ERROR"):
_plan, _summary, job = await self.run_apply("unused_components")
self.assertEqual(job.state, "failed")
self.assertEqual(job.last_event["code"], None)
self.assertEqual(job.status_line, "Maintenance failed; nothing was changed.")
self.assertNotIn(self.tmpdir, json.dumps(job.report()))
self.assertIsNotNone(self.api.db.get_prompt_by_id(orphan))
async def test_apply_errors_are_coded(self):
missing = await self.post(APPLY, {}, 400)
self.assertEqual(missing["code"], "invalid-request")
wrong = await self.post(APPLY, {"token": 7}, 400)
self.assertEqual(wrong["code"], "invalid-request")
extra = await self.post(APPLY, {"token": "x", "force": True}, 400)
self.assertEqual(extra["code"], "invalid-request")
expired = await self.apply("not-a-token", 410)
self.assertEqual(expired["code"], "plan-expired")
async def test_a_restore_after_the_review_is_storage_changed(self):
orphan = self.save("Orphan")
plan = await self.preview("unused_components")
self.api._database_replaced() # what the legacy restore calls
reply = await self.apply(plan["token"], 409)
self.assertEqual(reply["code"], "storage-changed")
again = await self.apply(plan["token"], 410) # the plan is forgotten
self.assertEqual(again["code"], "plan-expired")
self.assertIsNotNone(self.api.db.get_prompt_by_id(orphan))
async def test_restart_loses_plans_for_apply(self):
plan = await self.preview("optimize")
self.api._maintenance_plans = type(self.api._maintenance_plans)()
reply = await self.apply(plan["token"], 410)
self.assertEqual(reply["code"], "plan-expired")
async def test_safety_backups_are_pruned(self):
from py.api.admin import MAX_SAFETY_BACKUPS
for index in range(MAX_SAFETY_BACKUPS):
old = os.path.join(
self.tmpdir, f"prompts.db.backup_20000101_0000{index:02d}"
)
with open(old, "wb") as handle:
handle.write(b"old")
os.utime(old, (1000 + index, 1000 + index))
self.save("Orphan")
await self.run_apply("unused_components")
backups = self.backups()
self.assertEqual(len(backups), MAX_SAFETY_BACKUPS)
self.assertNotIn("prompts.db.backup_20000101_000000", backups)
class TestSafetyBackup(ApplyTestCase):
async def test_backup_names_unique_within_one_second(self):
from py.api import maintenance
frozen = datetime.datetime(2026, 10, 23, 12, 0, 0, 123456)
model = self.api.db.model
with patch.object(maintenance, "_now", return_value=frozen):
names = [maintenance.take_safety_backup(model) for _ in range(3)]
self.assertEqual(
names,
[
"prompts.db.backup_20261023_120000_123456",
"prompts.db.backup_20261023_120000_123456_1",
"prompts.db.backup_20261023_120000_123456_2",
],
)
for name in names:
ok, _reason = model.verify_database_file(os.path.join(self.tmpdir, name))
self.assertTrue(ok, name)
self.assertEqual(self.strays(), [])
async def test_backup_is_private_on_posix(self):
from py.api import maintenance
name = maintenance.take_safety_backup(self.api.db.model)
if os.name != "nt":
mode = os.stat(os.path.join(self.tmpdir, name)).st_mode & 0o777
self.assertEqual(mode, 0o600)
async def test_a_failed_replace_leaves_no_file(self):
from py.api import maintenance
with patch.object(maintenance.os, "replace", side_effect=OSError("busy")):
with self.assertRaises(maintenance.SafetyBackupFailed):
maintenance.take_safety_backup(self.api.db.model)
self.assertEqual(self.backups(), [])
self.assertEqual(self.strays(), [])
async def test_a_backup_that_cannot_be_finished_leaves_no_file(self):
from py.api import maintenance
real_connect = sqlite3.connect
calls = []
def connect(*args, **kwargs):
calls.append(args[0])
if len(calls) == 2: # the backup is written; finishing it fails
raise sqlite3.OperationalError("x")
return real_connect(*args, **kwargs)
with patch.object(maintenance.sqlite3, "connect", side_effect=connect):
with self.assertRaises(maintenance.SafetyBackupFailed) as failed:
maintenance.take_safety_backup(self.api.db.model)
self.assertEqual(str(failed.exception), "The backup could not be finished")
self.assertEqual(calls[0], calls[1]) # the temp copy, both times
self.assertEqual((self.backups(), self.strays()), ([], []))
async def test_a_safety_backup_restores_through_the_legacy_route(self):
from aiohttp import FormData
from py.api import maintenance
self.save("Before the backup")
name = maintenance.take_safety_backup(self.api.db.model)
self.save("After the backup")
with open(os.path.join(self.tmpdir, name), "rb") as handle:
upload = handle.read()
form = FormData()
form.add_field("database_file", upload, filename=name)
response = await self.client.request(
"POST", "/prompt_manager/restore", data=form
)
self.assertEqual(response.status, 200, await response.text())
texts = [p["text"] for p in self.api.db.search_prompts()]
self.assertEqual(texts, ["Before the backup"])
mode = self.api.db.model.get_connection().execute("PRAGMA journal_mode")
self.assertEqual(mode.fetchone()[0], "wal")
class TestSafetyBackupPolicy(MaintenanceAPITestCase):
async def test_policy_round_trips_with_revision(self):
settings = await self.client.request("GET", "/prompt_manager/settings")
body = await settings.json()
self.assertTrue(body["settings"]["safety_backup"])
saved = await self.post(
"/prompt_manager/settings",
{"safety_backup": False, "expected_revision": body["revision"]},
200,
)
self.assertTrue(saved["persisted"])
self.assertNotEqual(saved["revision"], body["revision"])
with open(self.config_path, encoding="utf-8") as handle:
self.assertIs(json.load(handle)["maintenance"]["safety_backup"], False)
again = await (
await self.client.request("GET", "/prompt_manager/settings")
).json()
self.assertFalse(again["settings"]["safety_backup"])
self.assertEqual(again["revision"], saved["revision"])
plan = await self.preview("optimize")
self.assertEqual(plan["backup"]["policy"], "off")
stale = await self.post(
"/prompt_manager/settings",
{"safety_backup": True, "expected_revision": body["revision"]},
409,
)
self.assertEqual(stale["code"], "settings-changed")
bad = await self.post("/prompt_manager/settings", {"safety_backup": "yes"}, 400)
self.assertFalse(bad["success"])
async def test_config_file_value_is_read_at_load(self):
from py.config import PromptManagerConfig
PromptManagerConfig.update_config({"maintenance": {"safety_backup": False}})
self.assertFalse(PromptManagerConfig.SAFETY_BACKUP)
PromptManagerConfig.update_config({"maintenance": {"safety_backup": "no"}})
self.assertFalse(PromptManagerConfig.SAFETY_BACKUP) # non-bool ignored
self.assertEqual(
PromptManagerConfig.get_config()["maintenance"], {"safety_backup": False}
)
class TestLegacyMaintenance(MaintenanceAPITestCase):
async def test_vacuum_failure_reported_not_success(self):
with patch(
+103
View File
@@ -428,6 +428,109 @@ class TestPreviews(MaintenanceDatabaseCase):
# The teardown's assert_closed proves the read-only handle was closed.
class TestApply(MaintenanceDatabaseCase):
"""``apply_maintenance`` rechecks the fingerprint inside BEGIN IMMEDIATE."""
def apply(self, draft, roots=None):
roots = [str(self.output)] if roots is None else roots
return self.db.apply_maintenance(
draft.operation, draft.fingerprint, draft.targets, roots
)
def test_unused_components_apply_removes_exactly_the_reviewed_prompts(self):
first, second = self.save("One"), self.save("Two")
draft = self.preview("unused_components")
with patch.object(maintenance, "ID_BATCH", 1): # one id per statement
result = self.apply(draft)
self.assertEqual(result["changed"], 2)
self.assertEqual(
[row["prompt_id"] for row in result["rows"]], sorted([first, second])
)
self.assertIsNone(self.db.get_prompt_by_id(first))
self.assertIsNone(self.db.get_prompt_by_id(second))
def test_changed_targets_raise_before_anything_is_written(self):
orphan, protected = self.save("Orphan"), self.save("Soon protected")
prompt = self.save("Links")
gone = self.image(prompt, "gone.png", exists=False)
kept = self.save("Kept")
self.duplicate("KEPT")
cases = {
"unused_components": lambda: self.db.model.get_connection().execute(
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
(protected, "Now a recipe"),
),
"missing_links": lambda: (self.output / "gone.png").write_bytes(b"back"),
"duplicate_prompts": lambda: self.db.update_prompt_metadata(kept, rating=4),
}
for operation, change in cases.items():
with self.subTest(operation=operation):
draft = self.preview(operation)
self.assertTrue(draft.targets)
change()
self.db.model.get_connection().commit()
with self.assertRaises(maintenance.PlanChanged):
self.apply(draft)
self.assertIsNotNone(self.db.get_prompt_by_id(orphan))
self.assertIsNotNone(self.db.get_image_by_id(gone))
self.assertEqual(len(self.db.search_prompts(text="kept")), 2)
def test_a_link_whose_source_went_away_is_never_removed(self):
folder = self.root / "drive"
prompt = self.save("Links")
link = self.image(prompt, "gone.png", folder=folder, exists=False)
roots = [str(folder)]
draft = self.preview("missing_links", roots)
self.assertEqual(draft.targets, [link])
folder.rmdir() # unmounted between the review and the apply
with self.assertRaises(maintenance.PlanChanged):
self.apply(draft, roots)
self.assertIsNotNone(self.db.get_image_by_id(link))
def test_missing_links_apply_removes_rows_only(self):
prompt = self.save("Links")
kept = self.image(prompt, "kept.png")
gone = [self.image(prompt, f"gone-{n}.png", exists=False) for n in range(3)]
media = {p.name: _digest(p) for p in self.output.iterdir()}
draft = self.preview("missing_links")
with patch.object(maintenance, "ID_BATCH", 2):
result = self.apply(draft)
self.assertEqual(result["changed"], 3)
self.assertEqual(
[os.path.basename(row["image_path"]) for row in result["rows"]],
[f"gone-{n}.png" for n in range(3)],
)
self.assertEqual({p.name: _digest(p) for p in self.output.iterdir()}, media)
self.assertIsNotNone(self.db.get_image_by_id(kept))
for image_id in gone:
self.assertIsNone(self.db.get_image_by_id(image_id))
def test_duplicate_prompts_apply_merges_into_the_oldest(self):
kept = self.save("A cat")
copy = self.duplicate("A CAT")
image = self.image(copy, "cat.png")
draft = self.preview("duplicate_prompts")
result = self.apply(draft)
self.assertEqual(result["changed"], 1)
self.assertEqual(result["rows"], [{"prompt_id": copy, "kept_id": kept}])
self.assertIsNone(self.db.get_prompt_by_id(copy))
self.assertEqual(self.db.get_image_by_id(image)["prompt_id"], kept)
def test_optimize_apply_vacuums(self):
self.save("Some")
draft = self.preview("optimize")
with patch.object(self.db.model, "vacuum_database") as vacuum:
self.assertEqual(self.apply(draft), {"changed": 1, "rows": []})
vacuum.assert_called_once_with()
self.assertEqual(self.apply(self.preview("optimize"))["changed"], 1)
with self.assertRaises(maintenance.PlanChanged):
self.db.apply_maintenance("optimize", "sha256:other", [], [])
def test_apply_of_an_unknown_operation_raises(self):
with self.assertRaises(ValueError):
self.db.apply_maintenance("reboot", "x", [], [])
class TestVacuum(MaintenanceDatabaseCase):
def test_vacuum_failure_raises(self):
with patch(