diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index cd7861fc..930d4f1a 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -56,6 +56,12 @@ logger = logging.getLogger(__name__) async def outputs_lifespan(): os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(WORKSPACE_DIR, exist_ok=True) + # One-time sweep: an app whose record vanished still has its work on disk, and no way for the user to know it is there. + try: + from backend.apps.outputs.recover_orphaned_apps import recover_orphaned_apps + recover_orphaned_apps() + except Exception: + logger.exception("orphaned-app recovery failed; apps stay hidden but nothing else breaks") try: yield finally: diff --git a/backend/apps/outputs/recover_orphaned_apps.py b/backend/apps/outputs/recover_orphaned_apps.py new file mode 100644 index 00000000..28417686 --- /dev/null +++ b/backend/apps/outputs/recover_orphaned_apps.py @@ -0,0 +1,105 @@ +"""Bring back apps whose workspace survived but whose record did not. + +An app is two things: an Output record and a workspace folder. The record is what makes it appear; +the folder is what holds the work. Lose the record and the work is still on disk, completely +invisible, with no way for the user to know it is there. + +Only workspaces that hold REAL work come back. A workspace whose meta.json says "Untitled App", or +has none at all, is a template seed that was created and never used; resurrecting those would hand +every user a pile of empty cards, which is a worse product than the bug. + +This runs once ever, behind a marker. Deleting an app leaves its workspace behind, so a recovery +that ran on every boot would make deletion impossible: the app would return the next morning. +""" + +import json +import logging +import os +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.outputs.models import Output +from backend.config.paths import DATA_ROOT, OUTPUTS_WORKSPACE_DIR + +logger = logging.getLogger(__name__) + +P_MARKER = os.path.join(DATA_ROOT, "orphan_app_recovery.done") +# What the seeder writes before a user has named anything, so it means "never used", not "an app". +P_PLACEHOLDER_NAMES = {"untitled app", "untitled", ""} + + +class RecoveryReport(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + scanned: int = 0 + recovered: List[str] = [] + skipped_unused: int = 0 + + +@typechecked +def p_real_name(workspace_dir: str) -> Optional[str]: + """The app's own name, or None when this workspace was never actually used.""" + meta = os.path.join(workspace_dir, "meta.json") + if not os.path.isfile(meta): + return None + try: + with open(meta, encoding="utf-8") as f: + data = json.load(f) + except (OSError, ValueError): + return None + name = data.get("name") + if not isinstance(name, str) or name.strip().lower() in P_PLACEHOLDER_NAMES: + return None + return name.strip() + + +@typechecked +def p_description(workspace_dir: str) -> str: + meta = os.path.join(workspace_dir, "meta.json") + try: + with open(meta, encoding="utf-8") as f: + desc = json.load(f).get("description") + return desc if isinstance(desc, str) else "" + except (OSError, ValueError): + return "" + + +@typechecked +def recover_orphaned_apps(force: bool = False) -> RecoveryReport: + """Re-register orphaned workspaces that hold real work. Runs once unless forced.""" + report = RecoveryReport() + # Cheap checks BEFORE the import: this runs on every boot and pays for itself exactly once. + if not force and os.path.exists(P_MARKER): + return report + if not os.path.isdir(OUTPUTS_WORKSPACE_DIR): + return report + from backend.apps.outputs.workspace_io import load_all, save + + claimed = {o.workspace_id for o in load_all() if o.workspace_id} + for entry in sorted(os.listdir(OUTPUTS_WORKSPACE_DIR)): + path = os.path.join(OUTPUTS_WORKSPACE_DIR, entry) + if not os.path.isdir(path) or entry in claimed: + continue + report.scanned += 1 + name = p_real_name(path) + if name is None: + report.skipped_unused += 1 + continue + try: + save(Output(name=name, description=p_description(path), workspace_id=entry)) + except Exception: + logger.warning("could not re-register orphaned app workspace %s", entry, exc_info=True) + continue + report.recovered.append(name) + + if report.recovered: + logger.info("recovered %d orphaned app(s): %s", len(report.recovered), ", ".join(report.recovered)) + try: + os.makedirs(os.path.dirname(P_MARKER), exist_ok=True) + with open(P_MARKER, "w", encoding="utf-8") as f: + f.write(json.dumps({"recovered": report.recovered, "skipped_unused": report.skipped_unused})) + except OSError: + logger.warning("could not write the app-recovery marker; it may run again", exc_info=True) + return report diff --git a/backend/tests/test_recover_orphaned_apps.py b/backend/tests/test_recover_orphaned_apps.py new file mode 100644 index 00000000..e49dca86 --- /dev/null +++ b/backend/tests/test_recover_orphaned_apps.py @@ -0,0 +1,145 @@ +"""An app whose record vanished but whose work survived must come back. + +An app is a record plus a workspace folder. Lose the record and the work sits on disk completely +invisible, with no way for the user to even know it is there. Measured on a real packaged store: +2 apps with genuine work ("Calculator", a 235-line rewrite of the template; "Voxelcraft", a +hand-written game.js) had no record. + +The hard part is NOT recovering. It is refusing to recover the other 6, which were template seeds +minted seconds apart by a page that has since been deleted and never touched again. Bringing those +back would hand every user a pile of empty cards, which is a worse product than the bug. + +Run: + cd backend && .venv/bin/python -m pytest tests/test_recover_orphaned_apps.py -v +""" + +from __future__ import annotations + +import json +import os + +import pytest + + +@pytest.fixture +def p_store(tmp_path, monkeypatch): + """A data root with a workspace dir, isolated from the real store.""" + root = tmp_path / "data" + ws = root / "outputs_workspace" + out = root / "outputs" + ws.mkdir(parents=True) + out.mkdir(parents=True) + import backend.apps.outputs.recover_orphaned_apps as mod + import backend.apps.outputs.workspace_io as wio + monkeypatch.setattr(mod, "OUTPUTS_WORKSPACE_DIR", str(ws)) + monkeypatch.setattr(mod, "P_MARKER", str(root / "orphan_app_recovery.done")) + monkeypatch.setattr(wio, "OUTPUTS_WORKSPACE_DIR", str(ws), raising=False) + monkeypatch.setattr(wio, "DATA_DIR", str(out), raising=False) + return {"root": root, "ws": ws, "out": out, "mod": mod, "wio": wio} + + +def p_workspace(p_store, wid: str, name=None, description="") -> str: + d = p_store["ws"] / wid + d.mkdir() + (d / "index.html").write_text("") + if name is not None: + (d / "meta.json").write_text(json.dumps({"name": name, "description": description})) + return wid + + +def p_names(p_store): + return sorted(o.name for o in p_store["wio"].load_all()) + + +def test_an_app_with_real_work_comes_back(p_store): + p_workspace(p_store, "ws-calc", name="Calculator", description="does sums") + + report = p_store["mod"].recover_orphaned_apps() + + assert report.recovered == ["Calculator"] + assert p_names(p_store) == ["Calculator"] + restored = p_store["wio"].load_all()[0] + assert restored.workspace_id == "ws-calc" + assert restored.description == "does sums", "the app's own meta.json is the source of truth" + + +def test_an_unused_template_seed_stays_buried(p_store): + """THE constraint. 6 of 8 real orphans were these; resurrecting them is worse than the bug.""" + p_workspace(p_store, "ws-seed", name="Untitled App") + + report = p_store["mod"].recover_orphaned_apps() + + assert report.recovered == [] + assert report.skipped_unused == 1 + assert p_names(p_store) == [] + + +def test_a_workspace_with_no_meta_stays_buried(p_store): + p_workspace(p_store, "ws-bare", name=None) + report = p_store["mod"].recover_orphaned_apps() + assert report.recovered == [] + assert report.skipped_unused == 1 + + +def test_the_real_mix_recovers_exactly_the_two(p_store): + """The packaged store as measured: 3 named 'Untitled App', 3 with no meta, 2 real.""" + for i in range(3): + p_workspace(p_store, f"ws-seed{i}", name="Untitled App") + for i in range(3): + p_workspace(p_store, f"ws-bare{i}", name=None) + p_workspace(p_store, "ws-calc", name="Calculator") + p_workspace(p_store, "ws-voxel", name="Voxelcraft") + + report = p_store["mod"].recover_orphaned_apps() + + assert sorted(report.recovered) == ["Calculator", "Voxelcraft"] + assert report.skipped_unused == 6 + + +def test_a_workspace_that_still_has_a_record_is_left_alone(p_store): + from backend.apps.outputs.models import Output + p_workspace(p_store, "ws-live", name="Live App") + p_store["wio"].save(Output(name="Live App", workspace_id="ws-live")) + + report = p_store["mod"].recover_orphaned_apps() + + assert report.recovered == [] + assert p_names(p_store) == ["Live App"], "must not create a duplicate record" + + +def test_it_runs_only_once(p_store): + """Deleting an app leaves its workspace behind, so a recovery that ran every boot would make + deletion impossible: the app would be back the next morning.""" + p_workspace(p_store, "ws-calc", name="Calculator") + assert p_store["mod"].recover_orphaned_apps().recovered == ["Calculator"] + + for o in p_store["wio"].load_all(): + os.remove(os.path.join(str(p_store["out"]), f"{o.id}.json")) + + assert p_store["mod"].recover_orphaned_apps().recovered == [] + assert p_names(p_store) == [], "the user deleted it; it must stay deleted" + + +def test_force_overrides_the_marker(p_store): + p_workspace(p_store, "ws-calc", name="Calculator") + p_store["mod"].recover_orphaned_apps() + for o in p_store["wio"].load_all(): + os.remove(os.path.join(str(p_store["out"]), f"{o.id}.json")) + + assert p_store["mod"].recover_orphaned_apps(force=True).recovered == ["Calculator"] + + +def test_a_blank_name_counts_as_unused(p_store): + p_workspace(p_store, "ws-blank", name=" ") + assert p_store["mod"].recover_orphaned_apps().skipped_unused == 1 + + +def test_unparseable_meta_does_not_break_the_sweep(p_store): + d = p_store["ws"] / "ws-broken" + d.mkdir() + (d / "meta.json").write_text("{not json") + p_workspace(p_store, "ws-calc", name="Calculator") + + report = p_store["mod"].recover_orphaned_apps() + + assert report.recovered == ["Calculator"], "one bad workspace must not strand the rest"