Skip to content

Commit 5c504ca

Browse files
committed
Add doctor/preflight diagnostics and harden file ops
1 parent e911d59 commit 5c504ca

14 files changed

Lines changed: 608 additions & 3 deletions

AGENTS.MD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ Implement:
4949
* Config file support
5050
* Logging
5151
* Dry-run mode
52+
* Doctor/preflight diagnostics mode before sync

AI_CONTEXT.MD

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ ALLOW DEVELOPERS TO CONTINUE WORK ON A SECOND MACHINE WITHOUT LOSING CODEX LOCAL
2727

2828
## RUN MODES
2929
- DIRECT RUN: MANUAL (`PYTHON ...` OR PACKAGED BINARY).
30+
- DIAGNOSTIC RUN: `DOCTOR`/`PREFLIGHT` TO VALIDATE ENVIRONMENT READINESS BEFORE SYNC.
3031
- SCHEDULED RUN:
3132
- WINDOWS: TASK SCHEDULER.
3233
- MACOS: `LAUNCHD` (LAUNCHAGENT) OR EQUIVALENT SCHEDULED JOB.
@@ -53,6 +54,13 @@ ALLOW DEVELOPERS TO CONTINUE WORK ON A SECOND MACHINE WITHOUT LOSING CODEX LOCAL
5354
- FORMATS: `TEXT|JSON|LOGFMT` (CONFIGURABLE).
5455
- ROTATION: RETENTION-BASED, DEFAULT `7` DAYS.
5556
- FILE-TIME NORMALIZATION: NO DEDICATED NORMALIZATION/CLOCK COMPENSATION IN MVP; RELY ON OS/FILESYSTEM TIMESTAMPS.
57+
- PREFLIGHT/DOCTOR CHECKS:
58+
- CONFIG + RUNTIME PATH READINESS.
59+
- WRITE ACCESS PROBES FOR LOCAL/CLOUD/BACKUP/TEMP ROOTS.
60+
- CODEX PROCESS SAFETY PRECONDITION.
61+
- MANIFEST DATA-VERSION COMPATIBILITY.
62+
- SUSPICIOUS LOCAL/CLOUD PROBE MTIME DRIFT (WARNING SIGNAL).
63+
- ORPHAN TEMP FILE DETECTION.
5664
- CLI EXIT CODES (FOR CI/AUTOMATION):
5765
- `0` SUCCESS (INCLUDING NO-OP).
5866
- `1` RUNTIME ERROR.

AI_RULES.MD

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
- CONFIGURATION FILE SUPPORT.
4242
- DETAILED LOGGING.
4343
- `--DRY-RUN` MODE.
44+
- `DOCTOR`/`PREFLIGHT` MODE THAT CHECKS RUNTIME READINESS BEFORE SYNC.
4445
- SCHEDULED MODE:
4546
- WINDOWS TASK SCHEDULER.
4647
- MACOS LAUNCHAGENT (`LAUNCHD`).
@@ -65,6 +66,11 @@
6566
- `4` CONFIG/ARGS ERROR (INVALID CONFIG OR CLI PARAMETERS).
6667
- `5` SAFE ABORT (FAIL-SAFE STOP).
6768

69+
`DOCTOR`/`PREFLIGHT` MUST:
70+
- REPORT CHECK RESULTS (`PASS|WARN|FAIL`) WITH CLEAR DETAILS.
71+
- RETURN `0` WHEN ONLY `PASS/WARN` ARE PRESENT.
72+
- RETURN `5` WHEN AT LEAST ONE `FAIL` IS PRESENT.
73+
6874
## 8. MINIMUM MVP TEST SET
6975
- UNIT TESTS FOR EXCLUSION FILTERING.
7076
- UNIT TESTS FOR SOURCE-SIDE SELECTION IN COMPARISON LOGIC.

README.MD

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ Validation:
112112
python -m codexsync -c config.toml validate
113113
```
114114

115+
Preflight diagnostics (same behavior for `doctor` and `preflight`):
116+
117+
```powershell
118+
python -m codexsync -c config.toml doctor
119+
python -m codexsync -c config.toml preflight
120+
```
121+
115122
Build sync plan (no changes):
116123

117124
```powershell
@@ -229,6 +236,11 @@ Termination-related exit codes for automation:
229236
- `4` invalid config or CLI arguments
230237
- `5` safe abort (`fail-safe`)
231238

239+
`doctor`/`preflight` return:
240+
241+
- `0` when all checks passed or warnings only
242+
- `5` when at least one preflight check failed
243+
232244
## Required operation protocol
233245

234246
This tool assumes a strict handoff flow between machines:

docs/DECISIONS.MD

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,15 @@ These checks are out of scope and owned by the user.
3232
## D-009: CI target matrix for MVP
3333
CI runs on Windows and macOS runners (`windows-latest`, `macos-latest`).
3434
Linux CI is intentionally disabled for MVP until Linux runtime support is explicitly in scope.
35+
36+
## D-010: Preflight diagnostics mode
37+
The CLI provides `doctor` and `preflight` commands (equivalent behavior).
38+
These checks are read-mostly and validate runtime readiness before sync:
39+
- config/runtime path readiness
40+
- local/cloud/backup/temp write access probes
41+
- Codex process precondition
42+
- manifest data-version compatibility
43+
- suspicious mtime drift between local/cloud probes
44+
- orphan temp file detection
45+
46+
If any preflight check fails, the command exits with code `5` (`fail-safe`).

docs/PROJECT_CONTEXT.MD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Key decisions:
1414
* Stop on two-sided file conflicts and require manual resolution + rerun
1515
* Keep logging configurable (`text|json|logfmt`) with 7-day default retention
1616
* In verbose mode (`-v`), print tracked Codex processes for plan/sync/restore diagnostics
17+
* Provide `doctor`/`preflight` diagnostics before sync to validate writable paths, process state, manifest compatibility, suspicious clock drift, and orphan temp files
1718
* Use explicit CLI exit codes for automation and CI
1819
* CI currently targets `windows-latest` and `macos-latest`; Linux CI is intentionally disabled in MVP
1920

src/codexsync/app.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
5583
def 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+
187272
def 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")

src/codexsync/backup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,11 @@ def prune(self) -> None:
7070
def _backup_file_zip(self, file_path: Path, relative_path: str) -> Path:
7171
snapshot_zip = self._snapshot_path
7272
snapshot_zip.parent.mkdir(parents=True, exist_ok=True)
73-
arcname = relative_path.replace("\\", "/")
74-
arcname = self._deduplicate_zip_entry(arcname)
7573
with zipfile.ZipFile(snapshot_zip, mode="a", compression=zipfile.ZIP_DEFLATED) as zf:
74+
if not self._zip_entries:
75+
self._zip_entries.update(zf.namelist())
76+
arcname = relative_path.replace("\\", "/")
77+
arcname = self._deduplicate_zip_entry(arcname)
7678
zf.write(file_path, arcname=arcname)
7779
return snapshot_zip
7880

src/codexsync/cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
from .app import (
88
build_context,
99
collect_process_snapshot,
10+
print_preflight_report,
1011
print_plan,
1112
restore_from_backup,
13+
run_preflight,
1214
run_sync,
1315
validate_config_only,
1416
)
@@ -49,6 +51,8 @@ def build_parser() -> argparse.ArgumentParser:
4951
sub = parser.add_subparsers(dest="command", required=True)
5052

5153
sub.add_parser("validate", help="Validate config only")
54+
sub.add_parser("doctor", help="Run environment diagnostics before sync")
55+
sub.add_parser("preflight", help="Run environment diagnostics before sync")
5256
sub.add_parser("plan", help="Build and print sync plan")
5357

5458
sync = sub.add_parser("sync", help="Run synchronization")
@@ -98,6 +102,15 @@ def main(argv: list[str] | None = None) -> int:
98102
print("Config is valid.")
99103
return int(ExitCode.OK)
100104

105+
if args.command in {"doctor", "preflight"}:
106+
report = run_preflight(config_path)
107+
print_preflight_report(report)
108+
if report.is_ok:
109+
print("Preflight checks passed.")
110+
return int(ExitCode.OK)
111+
print("Preflight checks failed.")
112+
return int(ExitCode.FAIL_SAFE)
113+
101114
if args.command == "plan":
102115
ctx = build_context(
103116
config_path,

src/codexsync/sync_engine.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ def __init__(
2626
self._fail_on_unknown = fail_on_unknown
2727

2828
def execute(self, plan: SyncPlan, dry_run: bool = True) -> None:
29+
if not dry_run:
30+
self._cleanup_orphaned_temp_files()
2931
for action in plan.to_local:
3032
self._copy(action, dry_run=dry_run)
3133
for action in plan.to_cloud:
@@ -50,8 +52,14 @@ def _copy(self, action: CopyAction, dry_run: bool) -> None:
5052
os.replace(staged, action.dst)
5153
except OSError:
5254
if self._fail_on_unknown:
55+
LOG.exception("atomic replace failed for staged file %s -> %s", staged, action.dst)
56+
raise
57+
try:
58+
shutil.copy2(staged, action.dst)
59+
except OSError:
60+
LOG.exception("fallback copy failed for staged file %s -> %s", staged, action.dst)
5361
raise
54-
shutil.copy2(staged, action.dst)
62+
finally:
5563
staged.unlink(missing_ok=True)
5664

5765
@staticmethod
@@ -69,3 +77,15 @@ def _stage_copy(self, action: CopyAction) -> Path:
6977
fallback.parent.mkdir(parents=True, exist_ok=True)
7078
shutil.copy2(action.src, fallback)
7179
return fallback
80+
81+
def _cleanup_orphaned_temp_files(self) -> None:
82+
if not self._temp_dir.exists():
83+
return
84+
removed = 0
85+
for path in self._temp_dir.rglob("*.tmp"):
86+
if not path.is_file():
87+
continue
88+
path.unlink(missing_ok=True)
89+
removed += 1
90+
if removed:
91+
LOG.info("removed %d orphaned temporary file(s) in %s", removed, self._temp_dir)

0 commit comments

Comments
 (0)