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
4791class 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