diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 0c3d3172..65aced57 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -346,33 +346,41 @@ async def seed_workspace(body: WorkspaceSeedRequest): "already_seeded": already_seeded, } - # Legacy flat path; unchanged. + # Legacy flat path. Seed only fills in MISSING files; it never overwrites + # what's already on disk. A reopen re-sends the inline output.files snapshot, + # which lags behind whatever the agent just wrote to the workspace; writing it + # back reverted every edited file (new files survived, edited ones snapped to + # the snapshot). Disk wins once an app exists. if body.files: for rel_path, content in body.files.items(): full_path = os.path.normpath(os.path.join(folder, rel_path)) if not full_path.startswith(os.path.normpath(folder)): continue + if os.path.exists(full_path): + continue os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, "w", encoding="utf-8") as f: f.write(content) else: for rel_path, content in VIEW_TEMPLATE_FILES.items(): full_path = os.path.join(folder, rel_path) + if os.path.exists(full_path): + continue with open(full_path, "w", encoding="utf-8") as f: f.write(content) - # Seed the workspace's SKILL.md with the LIVE skill content so an - # agent that Reads SKILL.md sees the same text the Skills page shows. - # Snapshot at workspace creation; subsequent edits don't rewrite - # already-seeded workspaces (the system-prompt injection in - # agent_manager reads live, so the agent always has the latest - # rules regardless of this on-disk copy). - with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f: - f.write(load_app_builder_skill()) + # SKILL.md is a creation-time snapshot; the live rules reach the agent via + # the system-prompt injection regardless, so never rewrite an existing one. + skill_path = os.path.join(folder, "SKILL.md") + if not os.path.exists(skill_path): + with open(skill_path, "w", encoding="utf-8") as f: + f.write(load_app_builder_skill()) if body.meta: - with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f: - json.dump(body.meta, f, indent=2) + meta_path = os.path.join(folder, "meta.json") + if not os.path.exists(meta_path): + with open(meta_path, "w", encoding="utf-8") as f: + json.dump(body.meta, f, indent=2) return {"path": os.path.abspath(folder), "template_mode": "flat"} diff --git a/backend/tests/test_seed_no_clobber.py b/backend/tests/test_seed_no_clobber.py new file mode 100644 index 00000000..3b62946a --- /dev/null +++ b/backend/tests/test_seed_no_clobber.py @@ -0,0 +1,77 @@ +"""Seed must CREATE, never overwrite. Reopening an app re-POSTs the inline +output.files snapshot, which lags behind whatever the agent last wrote to the +workspace on disk; seeding it back used to revert every edited file while the +agent's new files survived (edits looked half-reverted on the next export). + +Path constants are module-level, so (like test_versions) we monkeypatch them +into a temp tree. seed_workspace is async; we drive it with asyncio.run from a +sync test so the suite's bare-async-skip doesn't quietly no-op these.""" +import asyncio +import os + +import pytest + +from backend.apps.outputs import outputs as outputs_mod +from backend.apps.outputs.models import WorkspaceSeedRequest + + +@pytest.fixture +def ws_root(tmp_path, monkeypatch): + root = tmp_path / "ws" + root.mkdir() + monkeypatch.setattr(outputs_mod, "WORKSPACE_DIR", str(root)) + return root + + +def _seed(**kw): + return asyncio.run(outputs_mod.seed_workspace(WorkspaceSeedRequest(**kw))) + + +def _read(folder, rel): + with open(os.path.join(folder, rel), encoding="utf-8") as f: + return f.read() + + +def test_reopen_seed_preserves_agent_edits(ws_root): + wsid = "ws-reopen" + folder = os.path.join(str(ws_root), wsid) + os.makedirs(os.path.join(folder, "frontend", "src")) + # v1 on disk, captured into the inline snapshot the editor later autosaves. + with open(os.path.join(folder, "frontend", "src", "App.tsx"), "w") as f: + f.write("