mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] apps: bring back apps whose record vanished but whose work is still on disk
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user