codex-rescue 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Codex Rescue — local-first recovery for OpenAI Codex sessions."""
2
+
3
+ __version__ = "0.1.0a1"
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def canonical_json(data: Any) -> bytes:
11
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
12
+
13
+
14
+ def atomic_write(path: Path, data: bytes) -> None:
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
17
+ fd = os.open(temp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
18
+ try:
19
+ offset = 0
20
+ while offset < len(data):
21
+ offset += os.write(fd, data[offset:])
22
+ os.fsync(fd)
23
+ finally:
24
+ os.close(fd)
25
+ os.replace(temp, path)
26
+ if os.name != "nt":
27
+ dir_fd = os.open(path.parent, os.O_RDONLY)
28
+ try:
29
+ os.fsync(dir_fd)
30
+ finally:
31
+ os.close(dir_fd)
32
+
33
+
34
+ def write_rescue(root: Path, handoff: dict[str, Any], brief: str, continuation: str) -> tuple[str, Path]:
35
+ handoff_bytes = canonical_json(handoff)
36
+ rescue_id = hashlib.sha256(handoff_bytes).hexdigest()[:24]
37
+ rescue_dir = root / "rescues" / rescue_id
38
+ atomic_write(rescue_dir / "handoff.v1.json", handoff_bytes + b"\n")
39
+ atomic_write(rescue_dir / "RECOVERY_BRIEF.md", brief.encode("utf-8"))
40
+ atomic_write(rescue_dir / "CONTINUATION_PROMPT.md", continuation.encode("utf-8"))
41
+ return rescue_id, rescue_dir
42
+
43
+
44
+ def load_handoff(root: Path, rescue_id: str) -> dict[str, Any]:
45
+ path = root / "rescues" / rescue_id / "handoff.v1.json"
46
+ data = json.loads(path.read_text(encoding="utf-8"))
47
+ actual_id = hashlib.sha256(canonical_json(data)).hexdigest()[:24]
48
+ if actual_id != rescue_id:
49
+ raise ValueError(f"handoff hash mismatch: expected {rescue_id}, actual {actual_id}")
50
+ return data
51
+
codex_rescue/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .salvage import salvage_session
10
+ from .verify import verify_rescue
11
+
12
+
13
+ def _json(data: object) -> None:
14
+ print(json.dumps({"schema_version": 1, "data": data}, indent=2, ensure_ascii=False, sort_keys=True))
15
+
16
+
17
+ def _doctor(path: Path, oversized_threshold: int = 1_000_000):
18
+ from .doctor import doctor_session
19
+
20
+ result = doctor_session(path, oversized_threshold=oversized_threshold)
21
+ return result
22
+
23
+
24
+ def _to_dict(value: object) -> dict[str, object]:
25
+ if hasattr(value, "to_dict"):
26
+ return value.to_dict() # type: ignore[no-any-return,attr-defined]
27
+ if isinstance(value, dict):
28
+ return value
29
+ return value.__dict__.copy() # type: ignore[attr-defined]
30
+
31
+
32
+ def _parsed_from_doctor(result: object):
33
+ for name in ("transcript", "parse_result", "parsed"):
34
+ parsed = getattr(result, name, None)
35
+ if parsed is not None:
36
+ return parsed
37
+ if isinstance(result, dict):
38
+ for name in ("transcript", "parse_result", "parsed"):
39
+ if result.get(name) is not None:
40
+ return result[name]
41
+ raise RuntimeError("doctor result does not expose transcript parse result")
42
+
43
+
44
+ def _print_doctor(result: object) -> None:
45
+ data = _to_dict(result)
46
+ print(f"Doctor: {data.get('status', 'UNKNOWN_CORRUPTION')}")
47
+ session = data.get("session")
48
+ if session:
49
+ print(f"Session: {session}")
50
+ findings = list(data.get("findings") or [])
51
+ if findings:
52
+ print(f"Findings: {', '.join(str(item) for item in findings)}")
53
+ repository = data.get("repository")
54
+ if isinstance(repository, dict):
55
+ cwd = repository.get("cwd")
56
+ head = repository.get("head_sha")
57
+ if cwd or head:
58
+ print(f"Repository: {cwd or 'unknown'} (HEAD {head or 'unknown'})")
59
+
60
+
61
+ def _print_salvage(result: object) -> None:
62
+ data = _to_dict(result)
63
+ print(f"Salvage: {data.get('rescue_id', 'unknown')}")
64
+ untouched = data.get("original_untouched")
65
+ print(f"Original session untouched: {'yes' if untouched else 'no'}")
66
+ if data.get("rescue_dir"):
67
+ print(f"Rescue directory: {data['rescue_dir']}")
68
+ if data.get("continuation_command"):
69
+ print(f"Continue: {data['continuation_command']}")
70
+
71
+
72
+ def _print_verify(result: object) -> None:
73
+ data = _to_dict(result)
74
+ print(f"Verify: {data.get('status', 'REVIEW_REQUIRED')}")
75
+ conflicts = list(data.get("conflicts") or [])
76
+ reasons = list(data.get("review_reasons") or [])
77
+ for label, values in (("Conflict", conflicts), ("Review", reasons)):
78
+ for value in values:
79
+ print(f"{label}: {value}")
80
+
81
+
82
+ def main(argv: list[str] | None = None) -> int:
83
+ parser = argparse.ArgumentParser(prog="codex-rescue")
84
+ parser.add_argument("--version", action="version", version=f"codex-rescue {__version__}")
85
+ subs = parser.add_subparsers(dest="command")
86
+
87
+ sessions = subs.add_parser("sessions")
88
+ sessions.add_argument("--codex-home", type=Path)
89
+ sessions.add_argument("--limit", type=int, default=20)
90
+ sessions.add_argument("--latest", action="store_true")
91
+ sessions.add_argument("--json", action="store_true")
92
+
93
+ doctor = subs.add_parser("doctor")
94
+ doctor.add_argument("session", nargs="?", type=Path)
95
+ doctor.add_argument("--latest", action="store_true")
96
+ doctor.add_argument("--codex-home", type=Path)
97
+ doctor.add_argument("--json", action="store_true")
98
+ doctor.add_argument("--oversized-threshold", type=int, default=1_000_000)
99
+
100
+ salvage = subs.add_parser("salvage")
101
+ salvage.add_argument("session", nargs="?", type=Path)
102
+ salvage.add_argument("--latest", action="store_true")
103
+ salvage.add_argument("--codex-home", type=Path)
104
+ salvage.add_argument("--json", action="store_true")
105
+ salvage.add_argument("--oversized-threshold", type=int, default=1_000_000)
106
+ salvage.add_argument("--fork", action="store_true", required=True)
107
+ salvage.add_argument("--rescue-root", type=Path, default=Path(".codex-rescue"))
108
+
109
+ verify = subs.add_parser("verify")
110
+ verify.add_argument("rescue_id")
111
+ verify.add_argument("--rescue-root", type=Path, default=Path(".codex-rescue"))
112
+ verify.add_argument("--json", action="store_true")
113
+
114
+ args = parser.parse_args(argv)
115
+ if args.command in (None, "sessions"):
116
+ from .discovery import discover_sessions
117
+
118
+ limit = 1 if getattr(args, "latest", False) else getattr(args, "limit", 20)
119
+ summaries = discover_sessions(getattr(args, "codex_home", None), limit=limit)
120
+ data = [item.to_dict() for item in summaries]
121
+ if getattr(args, "json", False):
122
+ _json(data)
123
+ else:
124
+ print("Recent Codex sessions\n")
125
+ for index, item in enumerate(summaries, 1):
126
+ print(f"{index}. {item.modified_at} {item.status}")
127
+ print(f" repo: {item.repo or item.cwd or 'unknown'}")
128
+ print(f" prompt: {item.prompt_preview or 'unavailable'}")
129
+ if item.reason:
130
+ print(f" reason: {item.reason}")
131
+ return 0
132
+ if args.command == "doctor":
133
+ if args.latest:
134
+ from .discovery import resolve_latest
135
+ args.session = resolve_latest(args.codex_home)
136
+ if args.session is None:
137
+ parser.error("doctor requires SESSION or --latest (no session discovered)")
138
+ result = _doctor(args.session, args.oversized_threshold)
139
+ if args.json:
140
+ _json(_to_dict(result))
141
+ else:
142
+ _print_doctor(result)
143
+ return 0
144
+ if args.command == "salvage":
145
+ if args.latest:
146
+ from .discovery import resolve_latest
147
+ args.session = resolve_latest(args.codex_home)
148
+ if args.session is None:
149
+ parser.error("salvage requires SESSION or --latest (no session discovered)")
150
+ result = _doctor(args.session, args.oversized_threshold)
151
+ data = _to_dict(result)
152
+ status = str(data.get("status", "UNKNOWN_CORRUPTION"))
153
+ findings = list(data.get("findings") or [status])
154
+ salvage_result = salvage_session(
155
+ args.session,
156
+ _parsed_from_doctor(result),
157
+ status,
158
+ findings,
159
+ args.rescue_root,
160
+ args.fork,
161
+ )
162
+ if args.json:
163
+ _json(salvage_result.to_dict())
164
+ else:
165
+ _print_salvage(salvage_result)
166
+ return 0 if salvage_result.original_untouched else 2
167
+ if args.command == "verify":
168
+ result = verify_rescue(args.rescue_root, args.rescue_id)
169
+ if args.json:
170
+ _json(result.to_dict())
171
+ else:
172
+ _print_verify(result)
173
+ return 0 if result.status == "SAFE_TO_CONTINUE" else 3
174
+ return 2
175
+
176
+
177
+ if __name__ == "__main__":
178
+ raise SystemExit(main())