Skip to content

Commit a2f61e1

Browse files
committed
fix: long file path issue - windows
1 parent ac1b457 commit a2f61e1

1 file changed

Lines changed: 75 additions & 11 deletions

File tree

app/agent_app/manager.py

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,50 @@
4343
from app.triggers import TriggerService
4444

4545

46+
# ── Windows MAX_PATH ───────────────────────────────────────────────────────
47+
# Importing a large foreign repo copies a SHORT source path (a temp dir) to a
48+
# LONG destination path (the living_ui workspace, which sits under the user's
49+
# home + repo checkout). Odoo failed at exactly that step (2026-08-31): every
50+
# entry in the shutil.Error list read the source fine and died writing the
51+
# destination with "[Errno 2] No such file or directory" — the classic
52+
# MAX_PATH signature, not a missing file. A 218-char source became a 264-char
53+
# destination because the workspace prefix is 46 chars longer than the temp
54+
# prefix. The \\?\ extended-length prefix lifts the 260-char limit per call,
55+
# independent of the machine's LongPathsEnabled registry setting, so it works
56+
# on an unconfigured user box.
57+
def long_path(path: Any) -> str:
58+
"""Windows extended-length (\\\\?\\) form of *path*; unchanged elsewhere.
59+
60+
The prefix disables all path normalization in Win32, so the path MUST be
61+
absolute and separator-normalized first — os.path.abspath does both, and
62+
is a no-op on an already-prefixed path (so this is idempotent).
63+
"""
64+
p = os.fspath(path)
65+
if os.name != "nt":
66+
return p
67+
p = os.path.abspath(p)
68+
if p.startswith("\\\\?\\"):
69+
return p
70+
# UNC shares take the \\?\UNC\server\share form, not \\?\\\server\share.
71+
if p.startswith("\\\\"):
72+
return "\\\\?\\UNC\\" + p[2:]
73+
return "\\\\?\\" + p
74+
75+
76+
def copytree_long(src: Any, dst: Any, **kwargs: Any) -> str:
77+
"""shutil.copytree that survives paths over 260 chars on Windows."""
78+
return shutil.copytree(long_path(src), long_path(dst), **kwargs)
79+
80+
81+
def rmtree_long(path: Any, **kwargs: Any) -> None:
82+
"""shutil.rmtree that survives paths over 260 chars on Windows.
83+
84+
Deletion needs this as much as the copy: without it a deep tree that
85+
imported successfully could never be removed again.
86+
"""
87+
shutil.rmtree(long_path(path), **kwargs)
88+
89+
4690
@dataclass
4791
class AgentAppProject:
4892
"""Represents a Agent App project."""
@@ -2404,11 +2448,14 @@ async def import_project_source(
24042448
if self._find_project_root(root) is not None:
24052449
return await self._import_project_tree(root, name)
24062450
return await self._import_external_tree(root, name, origin=source)
2407-
with tempfile.TemporaryDirectory() as tmp:
2451+
# ignore_cleanup_errors: a deep foreign tree can carry paths this
2452+
# rmtree cannot reach, and losing a temp dir must never fail an
2453+
# otherwise-successful import.
2454+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
24082455
root = Path(tmp)
24092456
if kind == "zip":
24102457
with zipfile.ZipFile(source) as zf:
2411-
zf.extractall(root)
2458+
zf.extractall(long_path(root))
24122459
else:
24132460
self._fetch_git_source(source, root)
24142461
if self._find_project_root(root) is not None:
@@ -2436,7 +2483,9 @@ async def _import_external_tree(
24362483
port = self._allocate_port()
24372484
dest = self.agent_app_dir / f"{self._sanitize_name(display)}_{project_id}"
24382485
# node_modules is rebuilt by the install verb; .git/logs never import.
2439-
shutil.copytree(
2486+
# copytree_long, not shutil.copytree: a foreign repo can carry paths
2487+
# that only blow MAX_PATH once rebased onto the workspace prefix.
2488+
copytree_long(
24402489
src,
24412490
dest,
24422491
ignore=shutil.ignore_patterns("node_modules", ".git", "logs"),
@@ -2654,10 +2703,10 @@ async def import_project_zip(
26542703
import tempfile
26552704
import zipfile
26562705

2657-
with tempfile.TemporaryDirectory() as tmp:
2706+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
26582707
root = Path(tmp)
26592708
with zipfile.ZipFile(zip_path) as zf:
2660-
zf.extractall(root)
2709+
zf.extractall(long_path(root))
26612710
return await self._import_project_tree(root, name)
26622711

26632712
async def convert_foreign_source(
@@ -2679,13 +2728,13 @@ async def convert_foreign_source(
26792728
return await self._convert_tree(
26802729
Path(source).expanduser(), name, description, origin=source
26812730
)
2682-
with tempfile.TemporaryDirectory() as tmp:
2731+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
26832732
root = Path(tmp)
26842733
if kind == "zip":
26852734
import zipfile
26862735

26872736
with zipfile.ZipFile(source) as zf:
2688-
zf.extractall(root)
2737+
zf.extractall(long_path(root))
26892738
else:
26902739
self._fetch_git_source(source, root)
26912740
return await self._convert_tree(root, name, description, origin=source)
@@ -2844,14 +2893,25 @@ def _fetch_git_source(self, url: str, dest: Path) -> None:
28442893
req, timeout=60, context=ssl_ctx
28452894
).read()
28462895
with zipfile.ZipFile(io.BytesIO(data)) as zf:
2847-
zf.extractall(dest)
2896+
zf.extractall(long_path(dest))
28482897
return
28492898
except Exception as e:
28502899
last_err = e
28512900
raise RuntimeError(f"GitHub download failed for {url}: {last_err}")
28522901

2902+
# -c core.longpaths=true is git's own MAX_PATH escape hatch — without
2903+
# it a clone of a deeply-nested repo dies the same way the copy did.
28532904
result = subprocess.run(
2854-
["git", "clone", "--depth", "1", url, str(dest / "repo")],
2905+
[
2906+
"git",
2907+
"-c",
2908+
"core.longpaths=true",
2909+
"clone",
2910+
"--depth",
2911+
"1",
2912+
url,
2913+
str(dest / "repo"),
2914+
],
28552915
capture_output=True,
28562916
text=True,
28572917
timeout=120,
@@ -2896,7 +2956,7 @@ async def _import_project_tree(
28962956
# install step rebuilds it from package.json. .factory/.snapshots are
28972957
# the DONOR's lifecycle state (machine history, delivery stamp,
28982958
# legacy baseline) — a fresh identity must start a fresh lifecycle.
2899-
shutil.copytree(
2959+
copytree_long(
29002960
src,
29012961
dest,
29022962
ignore=shutil.ignore_patterns(
@@ -4017,7 +4077,11 @@ async def delete_project(
40174077
if living_root in project_path.parents:
40184078
if project_path.exists():
40194079
try:
4020-
shutil.rmtree(project_path)
4080+
# rmtree_long, not shutil.rmtree: an imported foreign
4081+
# tree may hold paths past MAX_PATH, and a project
4082+
# that cannot be deleted is stuck forever. The guard
4083+
# above ran on the plain path and is unaffected.
4084+
rmtree_long(project_path)
40214085
except Exception as e:
40224086
logger.error(
40234087
f"[AGENT_APP] Failed to delete project directory: {e}"

0 commit comments

Comments
 (0)