[eric] apps: bring back apps whose record vanished but whose work is still on disk

This commit is contained in:
ciregenz
2026-07-31 17:31:15 -07:00
parent 32d742813a
commit 0f6e110a86
3 changed files with 256 additions and 0 deletions
+6
View File
@@ -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:
@@ -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
+145
View File
@@ -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("<html></html>")
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"