@@ -52,6 +52,34 @@ class ProcessSnapshot:
5252 sandbox_detected : bool
5353
5454
55+ @dataclass (slots = True , frozen = True )
56+ class PreflightCheckResult :
57+ name : str
58+ status : str
59+ details : str
60+
61+
62+ @dataclass (slots = True )
63+ class PreflightReport :
64+ checks : list [PreflightCheckResult ]
65+
66+ @property
67+ def failures (self ) -> list [PreflightCheckResult ]:
68+ return [item for item in self .checks if item .status == "FAIL" ]
69+
70+ @property
71+ def warnings (self ) -> list [PreflightCheckResult ]:
72+ return [item for item in self .checks if item .status == "WARN" ]
73+
74+ @property
75+ def passed (self ) -> list [PreflightCheckResult ]:
76+ return [item for item in self .checks if item .status == "PASS" ]
77+
78+ @property
79+ def is_ok (self ) -> bool :
80+ return not self .failures
81+
82+
5583def build_context (
5684 config_path : Path ,
5785 manual_terminate_confirmation_override : bool | None = None ,
@@ -184,6 +212,63 @@ def validate_config_only(config_path: Path) -> None:
184212 raise
185213
186214
215+ def run_preflight (config_path : Path ) -> PreflightReport :
216+ checks : list [PreflightCheckResult ] = []
217+
218+ try :
219+ cfg = load_config (config_path )
220+ checks .append (PreflightCheckResult ("config" , "PASS" , f"Loaded config from { config_path } " ))
221+ except Exception as exc :
222+ checks .append (PreflightCheckResult ("config" , "FAIL" , f"Cannot load config: { exc } " ))
223+ return PreflightReport (checks = checks )
224+
225+ try :
226+ initialize_runtime_paths (cfg )
227+ checks .append (PreflightCheckResult ("runtime_paths" , "PASS" , "Runtime paths are initialized" ))
228+ except Exception as exc :
229+ checks .append (PreflightCheckResult ("runtime_paths" , "FAIL" , f"Runtime path initialization failed: { exc } " ))
230+ return PreflightReport (checks = checks )
231+
232+ local_dir : Path | None = None
233+ cloud_dir : Path | None = None
234+ try :
235+ local_dir , cloud_dir = resolve_state_dirs (cfg .paths .local_state_dir , cfg .paths .cloud_root_dir )
236+ checks .append (PreflightCheckResult ("state_dirs" , "PASS" , f"local={ local_dir } ; cloud={ cloud_dir } " ))
237+ except Exception as exc :
238+ checks .append (PreflightCheckResult ("state_dirs" , "FAIL" , f"State directories are not ready: { exc } " ))
239+
240+ if local_dir is not None :
241+ checks .append (_check_write_access ("local_write" , local_dir ))
242+ if cloud_dir is not None :
243+ checks .append (_check_write_access ("cloud_write" , cloud_dir ))
244+
245+ checks .append (_check_write_access ("backup_write" , cfg .paths .backup_dir ))
246+ checks .append (_check_write_access ("temp_write" , cfg .paths .temp_dir ))
247+
248+ try :
249+ _ = load_manifest (cfg .state .manifest_file , cfg .state .data_version )
250+ checks .append (PreflightCheckResult ("manifest" , "PASS" , "Manifest data version is compatible" ))
251+ except Exception as exc :
252+ checks .append (PreflightCheckResult ("manifest" , "FAIL" , f"Manifest check failed: { exc } " ))
253+
254+ checks .append (_check_process_state (cfg ))
255+ if local_dir is not None and cloud_dir is not None :
256+ checks .append (_check_clock_drift (local_dir , cloud_dir ))
257+ checks .append (_check_orphan_temp_files (cfg .paths .temp_dir ))
258+
259+ return PreflightReport (checks = checks )
260+
261+
262+ def print_preflight_report (report : PreflightReport ) -> None :
263+ print ("Preflight report:" )
264+ for item in report .checks :
265+ print (f" [{ item .status } ] { item .name } : { item .details } " )
266+ print (
267+ "Summary: "
268+ f"pass={ len (report .passed )} warn={ len (report .warnings )} fail={ len (report .failures )} "
269+ )
270+
271+
187272def initialize_runtime_paths (cfg : AppConfig ) -> None :
188273 """
189274 Prepares runtime directories/files for first run.
@@ -546,3 +631,72 @@ def _is_included_root(relative_path: str, include_roots: list[str]) -> bool:
546631 if rel == root or rel .startswith (f"{ root } /" ):
547632 return True
548633 return False
634+
635+
636+ def _check_write_access (name : str , directory : Path ) -> PreflightCheckResult :
637+ probe = directory / f".codexsync-preflight-{ uuid .uuid4 ().hex } .tmp"
638+ try :
639+ directory .mkdir (parents = True , exist_ok = True )
640+ with probe .open ("w" , encoding = "utf-8" ) as fh :
641+ fh .write ("ok" )
642+ return PreflightCheckResult (name , "PASS" , f"Write access confirmed for { directory } " )
643+ except Exception as exc :
644+ return PreflightCheckResult (name , "FAIL" , f"Cannot write to { directory } : { exc } " )
645+ finally :
646+ probe .unlink (missing_ok = True )
647+
648+
649+ def _check_process_state (cfg : AppConfig ) -> PreflightCheckResult :
650+ if not cfg .safety .require_codex_stopped :
651+ return PreflightCheckResult ("codex_process" , "WARN" , "Process check is disabled by safety.require_codex_stopped=false" )
652+
653+ try :
654+ snapshot = collect_process_snapshot (cfg )
655+ except Exception as exc :
656+ if cfg .safety .fail_on_unknown :
657+ return PreflightCheckResult ("codex_process" , "FAIL" , f"Cannot verify process state safely: { exc } " )
658+ return PreflightCheckResult ("codex_process" , "WARN" , f"Process check unavailable: { exc } " )
659+
660+ if snapshot .sandbox_detected :
661+ return PreflightCheckResult ("codex_process" , "FAIL" , "codex-windows-sandbox is running" )
662+ if snapshot .main_processes :
663+ details = ", " .join (f"{ proc .name } :{ proc .pid } " for proc in snapshot .main_processes )
664+ return PreflightCheckResult ("codex_process" , "FAIL" , f"Codex process is running: { details } " )
665+ return PreflightCheckResult ("codex_process" , "PASS" , "Codex process is not running" )
666+
667+
668+ def _check_clock_drift (local_dir : Path , cloud_dir : Path ) -> PreflightCheckResult :
669+ local_probe = local_dir / f".codexsync-clock-{ uuid .uuid4 ().hex } .tmp"
670+ cloud_probe = cloud_dir / f".codexsync-clock-{ uuid .uuid4 ().hex } .tmp"
671+ threshold_seconds = 5
672+ try :
673+ local_probe .write_text ("clock" , encoding = "utf-8" )
674+ cloud_probe .write_text ("clock" , encoding = "utf-8" )
675+ local_mtime = local_probe .stat ().st_mtime_ns
676+ cloud_mtime = cloud_probe .stat ().st_mtime_ns
677+ delta_seconds = abs (local_mtime - cloud_mtime ) / 1_000_000_000
678+ if delta_seconds > threshold_seconds :
679+ return PreflightCheckResult (
680+ "clock_drift" ,
681+ "WARN" ,
682+ f"Suspicious mtime delta between local/cloud probes: { delta_seconds :.3f} s (> { threshold_seconds } s)" ,
683+ )
684+ return PreflightCheckResult ("clock_drift" , "PASS" , f"Probe mtime delta={ delta_seconds :.3f} s" )
685+ except Exception as exc :
686+ return PreflightCheckResult ("clock_drift" , "WARN" , f"Clock drift probe failed: { exc } " )
687+ finally :
688+ local_probe .unlink (missing_ok = True )
689+ cloud_probe .unlink (missing_ok = True )
690+
691+
692+ def _check_orphan_temp_files (temp_dir : Path ) -> PreflightCheckResult :
693+ if not temp_dir .exists ():
694+ return PreflightCheckResult ("orphan_temp_files" , "PASS" , "Temp directory does not exist yet" )
695+ orphan_files = [path for path in temp_dir .rglob ("*.tmp" ) if path .is_file ()]
696+ if orphan_files :
697+ return PreflightCheckResult (
698+ "orphan_temp_files" ,
699+ "WARN" ,
700+ f"Found { len (orphan_files )} orphan temp file(s) in { temp_dir } " ,
701+ )
702+ return PreflightCheckResult ("orphan_temp_files" , "PASS" , "No orphan temp files found" )
0 commit comments