diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index b841ca00..0293ff3b 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -315,6 +315,13 @@ def p_read_payload(sandbox: str, ref: EntityRef) -> dict: return json.load(f) +def bundle_key(full: str, base: str) -> str: + """A staged file's key inside the bundle: slash-separated on every OS. On Windows relpath gave + `workspace\\app\\...`, the app importer looked for `workspace/`, skipped every file, and saved a + hollow app that then said its files were missing (the Tisusa import, 2026-09-04).""" + return os.path.relpath(full, base).replace(os.sep, "/") + + def p_read_files(sandbox: str, ref: EntityRef) -> dict[str, bytes]: base = p_safe_join(sandbox, os.path.join(ref.path, "files")) out: dict[str, bytes] = {} @@ -324,7 +331,7 @@ def p_read_files(sandbox: str, ref: EntityRef) -> dict[str, bytes]: for fn in fnames: full = os.path.join(root, fn) with open(full, "rb") as f: - out[os.path.relpath(full, base)] = f.read() + out[bundle_key(full, base)] = f.read() return out diff --git a/backend/apps/swarm/entities/apps.py b/backend/apps/swarm/entities/apps.py index 409a871f..828cfd0c 100644 --- a/backend/apps/swarm/entities/apps.py +++ b/backend/apps/swarm/entities/apps.py @@ -93,6 +93,10 @@ class AppExportable: with open(dest, "wb") as f: f.write(data) wrote_workspace = True + if files and not wrote_workspace: + # The bundle shipped files but none under workspace/: a hollow app would import "successfully" and + # then say its files are missing. Fail here, where the rollback still knows what to undo. + raise ValueError("the app's files are not where the bundle format puts them; nothing was imported") if wrote_workspace: p_localize_env(folder) diff --git a/backend/apps/swarm/entities/skills.py b/backend/apps/swarm/entities/skills.py index 8530d638..0c604c35 100644 --- a/backend/apps/swarm/entities/skills.py +++ b/backend/apps/swarm/entities/skills.py @@ -111,7 +111,8 @@ def read_supporting_files(skill_dir: str) -> dict[str, bytes]: p_dirs[:] = [d for d in p_dirs if d not in WALK_SKIP_DIRS] for n in names: full = os.path.join(root, n) - rel = os.path.relpath(full, skill_dir) + # Slash keys on every OS, like the app exporter; a Windows export used to ship `scripts\\x.py`. + rel = os.path.relpath(full, skill_dir).replace(os.sep, "/") if rel == "SKILL.md" or n.startswith("."): continue try: diff --git a/backend/tests/test_swarm_windows_paths.py b/backend/tests/test_swarm_windows_paths.py new file mode 100644 index 00000000..75a86725 --- /dev/null +++ b/backend/tests/test_swarm_windows_paths.py @@ -0,0 +1,38 @@ +"""A Windows user imported the Tisusa .swarm (507 workspace files) and got an app card that said +"This app's files are missing" (2026-09-04). The staging reader keyed files with the OS separator, +the app importer looked for `workspace/`, skipped every file, and saved a hollow app. Bundle keys +are slash-separated everywhere now, and a bundle that ships files but none under workspace/ fails +loudly instead of importing a shell.""" + +import os + +import pytest + +from backend.apps.swarm import closure +from backend.apps.swarm.entities.apps import AppExportable + + +def test_bundle_keys_are_slashes_even_when_the_os_answers_with_backslashes(monkeypatch): + real_relpath = os.path.relpath + # The Windows shape: relpath answers with backslashes and the separator is a backslash. + monkeypatch.setattr(os.path, "relpath", lambda full, start: real_relpath(full, start).replace("/", "\\")) + monkeypatch.setattr(os, "sep", "\\") + assert closure.bundle_key("/sb/entities/e1/files/workspace/app/index.html", "/sb/entities/e1/files") == "workspace/app/index.html" + + +def test_the_staging_reader_uses_the_slash_keys(tmp_path): + base = tmp_path / "entities" / "e1" / "files" / "workspace" / "app" + base.mkdir(parents=True) + (base / "index.html").write_bytes(b"

x

") + ref = type("Ref", (), {"path": "entities/e1"})() + assert list(closure.p_read_files(str(tmp_path), ref)) == ["workspace/app/index.html"] + src = open(closure.__file__).read() + assert "out[bundle_key(full, base)]" in src + + +def test_a_bundle_whose_files_miss_the_workspace_prefix_fails_loudly(tmp_path, monkeypatch): + monkeypatch.setattr("backend.apps.swarm.entities.apps.OUTPUTS_WORKSPACE_DIR", str(tmp_path / "ws")) + monkeypatch.setattr("backend.apps.swarm.entities.apps.OUTPUTS_DIR", str(tmp_path / "out")) + with pytest.raises(ValueError, match="nothing was imported"): + AppExportable.import_({"name": "Hollow"}, {"workspace\\\\app\\\\index.html": b"

x

"}, {}) + assert not (tmp_path / "out").exists() or not list((tmp_path / "out").glob("*.json"))