Whole file
GODGOD126/codex-history-sync-tool
The author described this change as “fix: support modern Codex state database path”. It counts as a record because the checks below fail on the code as it stood at de2ba346e and pass on 0925066f2, with nothing else changed between the two runs.
Fix saved2026-06-14
Sharing licenceMIT · LICENSE
Change size+799 −795
What the code was meant to do, written into the code itself as a save note
fix: support modern Codex state database path
The change
| 49 | 49 | ||
| 50 | 50 | def resolve_paths(codex_home: str | None) -> Paths: | |
| 51 | 51 | home = Path(codex_home).expanduser() if codex_home else default_codex_home() | |
| 52 | - | return Paths( | |
| 53 | - | codex_home=home, | |
| 54 | - | config_path=home / "config.toml", | |
| 55 | - | db_path=home / "state_5.sqlite", | |
| 56 | - | backup_dir=home / "history_sync_backups", | |
| 57 | - | session_index_path=home / "session_index.jsonl", | |
| 58 | - | sessions_dir=home / "sessions", | |
| 59 | - | ) | |
| 60 | - | ||
| 61 | - | ||
| 62 | - | def read_text(path: Path) -> str: | |
| 63 | - | return path.read_text(encoding="utf-8") | |
| 64 | - | ||
| 65 | - | ||
| 66 | - | def read_text_exact(path: Path) -> str: | |
| 67 | - | with path.open("r", encoding="utf-8", newline="") as handle: | |
| 68 | - | return handle.read() | |
| 69 | - | ||
| 70 | - | ||
| 71 | - | def replace_file_with_retry(source_path: Path, target_path: Path) -> None: | |
| 72 | - | last_error: OSError | None = None | |
| 73 | - | for attempt in range(FILE_REPLACE_RETRY_LIMIT): | |
| 74 | - | try: | |
| 75 | - | # 用原子替换避免写到一半被 Codex 读到半成品文件。 | |
| 76 | - | source_path.replace(target_path) | |
| 77 | - | return | |
| 78 | - | except PermissionError as exc: | |
| 79 | - | last_error = exc | |
| 80 | - | except OSError as exc: | |
| 81 | - | if getattr(exc, "winerror", None) not in (5, 32): | |
| 82 | - | raise | |
| 83 | - | last_error = exc | |
| 84 | - | ||
| 85 | - | if attempt < FILE_REPLACE_RETRY_LIMIT - 1: | |
| 86 | - | time.sleep(FILE_REPLACE_RETRY_DELAY_SECONDS) | |
| 87 | - | ||
| 88 | - | raise RuntimeError(f"File is busy and could not be replaced: {target_path}") from last_error | |
| 89 | - | ||
| 90 | - | ||
| 91 | - | def write_text_exact(path: Path, text: str) -> None: | |
| 92 | - | temp_path = path.with_name(f".{path.name}.codex-sync-{time.time_ns()}.tmp") | |
| 93 | - | try: | |
| 94 | - | with temp_path.open("w", encoding="utf-8", newline="") as handle: | |
| 95 | - | handle.write(text) | |
| 96 | - | replace_file_with_retry(temp_path, path) | |
| 97 | - | finally: | |
| 98 | - | if temp_path.exists(): | |
| 99 | - | temp_path.unlink() | |
| 100 | - | ||
| 101 | - | ||
| 102 | - | def parse_current_provider(config_text: str) -> str: | |
| 103 | - | match = re.search(r'(?m)^\s*model_provider\s*=\s*"([^"]+)"', config_text) | |
| 104 | - | if not match: | |
| 105 | - | raise RuntimeError("Could not find model_provider in config.toml.") | |
| 106 | - | return match.group(1) | |
| 107 | - | ||
| 108 | - | ||
| 109 | - | def parse_current_model(config_text: str) -> str | None: | |
| 110 | - | match = re.search(r'(?m)^\s*model\s*=\s*"([^"]+)"', config_text) | |
| 111 | - | return match.group(1) if match else None | |
| 112 | - | ||
| 113 | - | ||
| 114 | - | @contextmanager | |
| 115 | - | def connect_db( | |
| 116 | - | path: Path, | |
| 117 | - | readonly: bool = False, | |
| 118 | - | timeout_seconds: float = DEFAULT_DB_TIMEOUT_SECONDS, | |
| 119 | - | busy_timeout_ms: int | None = None, | |
| 120 | - | ) -> Iterator[sqlite3.Connection]: | |
| 121 | - | if busy_timeout_ms is None: | |
| 122 | - | busy_timeout_ms = max(1, int(timeout_seconds * 1000)) | |
| 123 | - | ||
| 124 | - | if readonly: | |
| 125 | - | conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=timeout_seconds) | |
| 126 | - | else: | |
| 127 | - | conn = sqlite3.connect(str(path), timeout=timeout_seconds) | |
| 128 | - | ||
| 129 | - | try: | |
| 130 | - | conn.execute(f"PRAGMA busy_timeout = {busy_timeout_ms}") | |
| 131 | - | conn.row_factory = sqlite3.Row | |
| 132 | - | yield conn | |
| 133 | - | finally: | |
| 134 | - | conn.close() | |
| 135 | - | ||
| 136 | - | ||
| 137 | - | def ensure_environment(paths: Paths) -> None: | |
| 138 | - | if not paths.config_path.exists(): | |
| 139 | - | raise RuntimeError(f"Missing config file: {paths.config_path}") | |
| 140 | - | if not paths.db_path.exists(): | |
| 141 | - | raise RuntimeError(f"Missing database file: {paths.db_path}") | |
| 142 | - | ||
| 143 | - | ||
| 144 | - | def get_thread_columns(conn: sqlite3.Connection) -> set[str]: | |
| 145 | - | return {str(row["name"]) for row in conn.execute("PRAGMA table_info(threads)")} | |
| 146 | - | ||
| 147 | - | ||
| 148 | - | def counts_to_rows(counts: OrderedDict[str, int]) -> list[dict[str, object]]: | |
| 149 | - | return [{"provider": key, "count": value} for key, value in counts.items()] | |
| 150 | - | ||
| 151 | - | ||
| 152 | - | def model_counts_to_rows(counts: OrderedDict[str, int]) -> list[dict[str, object]]: | |
| 153 | - | return [{"model": key, "count": value} for key, value in counts.items()] | |
| 154 | - | ||
| 155 | - | ||
| 156 | - | def ordered_counts(values: list[str]) -> OrderedDict[str, int]: | |
| 157 | - | raw_counts: dict[str, int] = {} | |
| 158 | - | for value in values: | |
| 159 | - | key = value or "(empty)" | |
| 160 | - | raw_counts[key] = raw_counts.get(key, 0) + 1 | |
| 161 | - | ||
| 162 | - | counts = OrderedDict() | |
| 163 | - | for key, value in sorted(raw_counts.items(), key=lambda item: (-item[1], item[0])): | |
| 164 | - | counts[key] = value | |
| 165 | - | return counts | |
| 166 | - | ||
| 167 | - | ||
| 168 | - | def elapsed_ms(started_at: float) -> int: | |
| 169 | - | return int((time.monotonic() - started_at) * 1000) | |
| 170 | - | ||
| 171 | - | ||
| 172 | - | def query_provider_counts(conn: sqlite3.Connection) -> OrderedDict[str, int]: | |
| 173 | - | counts = OrderedDict() | |
| 174 | - | for provider, count in conn.execute( | |
| 175 | - | """ | |
| 176 | - | SELECT model_provider, COUNT(*) | |
| 177 | - | FROM threads | |
| 178 | - | GROUP BY model_provider | |
| 179 | - | ORDER BY COUNT(*) DESC, model_provider ASC | |
| 180 | - | """ | |
| 181 | - | ): | |
| 182 | - | counts[str(provider or "(empty)")] = int(count) | |
| 183 | - | return counts | |
| 184 | - | ||
| 185 | - | ||
| 186 | - | def query_model_counts(conn: sqlite3.Connection) -> OrderedDict[str, int]: | |
| 187 | - | counts = OrderedDict() | |
| 188 | - | for model, count in conn.execute( | |
| 189 | - | """ | |
| 190 | - | SELECT model, COUNT(*) | |
| 191 | - | FROM threads | |
| 192 | - | GROUP BY model | |
| 193 | - | ORDER BY COUNT(*) DESC, model ASC | |
| 194 | - | """ | |
| 195 | - | ): | |
| 196 | - | counts[str(model or "(empty)")] = int(count) | |
| 197 | - | return counts | |
| 198 | - | ||
| 199 | - | ||
| 200 | - | def query_provider_model_counts(conn: sqlite3.Connection) -> list[dict[str, object]]: | |
| 201 | - | rows = [] | |
| 202 | - | for provider, model, count in conn.execute( | |
| 203 | - | """ | |
| 204 | - | SELECT model_provider, model, COUNT(*) | |
| 205 | - | FROM threads | |
| 206 | - | GROUP BY model_provider, model | |
| 207 | - | ORDER BY COUNT(*) DESC, model_provider ASC, model ASC | |
| 208 | - | """ | |
| 209 | - | ): | |
| 210 | - | rows.append( | |
| 211 | - | { | |
| 212 | - | "provider": str(provider or "(empty)"), | |
| 213 | - | "model": str(model or "(empty)"), | |
| 214 | - | "count": int(count), | |
| 215 | - | } | |
| 216 | - | ) | |
| 217 | - | return rows | |
| 218 | - | ||
| 219 | - | ||
| 220 | - | def query_cwd_counts(conn: sqlite3.Connection, limit: int = 20) -> list[dict[str, object]]: | |
| 221 | - | rows = [] | |
| 222 | - | for cwd, count in conn.execute( | |
| 223 | - | """ | |
| 224 | - | SELECT cwd, COUNT(*) | |
| 225 | - | FROM threads | |
| 226 | - | GROUP BY cwd | |
| 227 | - | ORDER BY COUNT(*) DESC, cwd ASC | |
| 228 | - | LIMIT ? | |
| 229 | - | """, | |
| 230 | - | (limit,), | |
| 231 | - | ): | |
| 232 | - | rows.append({"cwd": str(cwd or "(empty)"), "count": int(count)}) | |
| 233 | - | return rows | |
| 234 | - | ||
| 235 | - | ||
| 236 | - | def count_mismatched(conn: sqlite3.Connection, column: str, expected: str | None) -> int | None: | |
| 237 | - | if expected is None: | |
| 238 | - | return None | |
| 239 | - | return int( | |
| 240 | - | conn.execute( | |
| 241 | - | f"SELECT COUNT(*) FROM threads WHERE {column} IS NULL OR {column} <> ?", | |
| 242 | - | (expected,), | |
| 243 | - | ).fetchone()[0] | |
| 244 | - | ) | |
| 245 | - | ||
| 246 | - | ||
| 247 | - | def list_backups(paths: Paths, limit: int = 20) -> list[dict[str, str]]: | |
| 248 | - | if not paths.backup_dir.exists(): | |
| 249 | - | return [] | |
| 250 | - | files = sorted( | |
| 251 | - | paths.backup_dir.glob("state_5.sqlite.*.bak"), | |
| 252 | - | key=lambda item: item.stat().st_mtime, | |
| 253 | - | reverse=True, | |
| 254 | - | ) | |
| 255 | - | output = [] | |
| 256 | - | for item in files[:limit]: | |
| 257 | - | output.append( | |
| 258 | - | { | |
| 259 | - | "name": item.name, | |
| 260 | - | "path": str(item), | |
| 261 | - | "modified_at": datetime.fromtimestamp(item.stat().st_mtime).isoformat(timespec="seconds"), | |
| 262 | - | } | |
| 263 | - | ) | |
| 264 | - | return output | |
| 265 | - | ||
| 266 | - | ||
| 267 | - | def split_first_line(text: str) -> tuple[str, str, str]: | |
| 268 | - | for ending in ("\r\n", "\n", "\r"): | |
| 269 | - | index = text.find(ending) | |
| 270 | - | if index >= 0: | |
| 271 | - | return text[:index], ending, text[index + len(ending) :] | |
| 272 | - | return text, "", "" | |
| 273 | - | ||
| 274 | - | ||
| 275 | - | def replace_first_line(path: Path, first_line: str) -> None: | |
| 276 | - | text = read_text_exact(path) | |
| 277 | - | _, ending, remainder = split_first_line(text) | |
| 278 | - | if ending: | |
| 279 | - | new_text = first_line + ending + remainder | |
| 280 | - | elif text: | |
| 281 | - | new_text = first_line | |
| 282 | - | else: | |
| 283 | - | new_text = first_line + "\n" | |
| 284 | - | write_text_exact(path, new_text) | |
| 285 | - | ||
| 286 | - | ||
| 287 | - | def session_index_backup_path(backup_path: Path) -> Path: | |
| 288 | - | return backup_path.with_name(f"{backup_path.name}.session_index.jsonl") | |
| 289 | - | ||
| 290 | - | ||
| 291 | - | def session_meta_backup_path(backup_path: Path) -> Path: | |
| 292 | - | return backup_path.with_name(f"{backup_path.name}.session_meta.json") | |
| 293 | - | ||
| 294 | - | ||
| 295 | - | def iter_session_paths(paths: Paths) -> list[Path]: | |
| 296 | - | if not paths.sessions_dir.exists(): | |
| 297 | - | return [] | |
| 298 | - | return sorted(paths.sessions_dir.rglob("rollout-*.jsonl")) | |
| 299 | - | ||
| 300 | - | ||
| 301 | - | def parse_session_record(path: Path) -> SessionRecord | None: | |
| 302 | - | if not SESSION_FILENAME_PATTERN.search(path.name): | |
| 303 | - | return None | |
| 304 | - | ||
| 305 | - | with path.open("r", encoding="utf-8", newline="") as handle: | |
| 306 | - | first_line = handle.readline() | |
| 307 | - | ||
| 308 | - | if not first_line: | |
| 309 | - | return None | |
| 310 | - | ||
| 311 | - | item = json.loads(first_line.rstrip("\r\n")) | |
| 312 | - | if item.get("type") != "session_meta": | |
| 313 | - | return None | |
| 314 | - | ||
| 315 | - | payload = item.get("payload") | |
| 316 | - | if not isinstance(payload, dict): | |
| 317 | - | return None | |
| 318 | - | ||
| 319 | - | thread_id = str(payload.get("id") or "").strip() | |
| 320 | - | if not thread_id: | |
| 321 | - | return None | |
| 322 | - | ||
| 323 | - | model_provider = str(payload.get("model_provider") or "") | |
| 324 | - | raw_model = payload.get("model") | |
| 325 | - | model = str(raw_model) if raw_model else None | |
| 326 | - | return SessionRecord(thread_id=thread_id, path=path, model_provider=model_provider, model=model) | |
| 327 | - | ||
| 328 | - | ||
| 329 | - | def scan_session_records(paths: Paths) -> list[SessionRecord]: | |
| 330 | - | records: list[SessionRecord] = [] | |
| 331 | - | for path in iter_session_paths(paths): | |
| 332 | - | record = parse_session_record(path) | |
| 333 | - | if record: | |
| 334 | - | records.append(record) | |
| 335 | - | return records | |
| 336 | - | ||
| 337 | - | ||
| 338 | - | def read_session_index(paths: Paths) -> OrderedDict[str, dict[str, str]]: | |
| 339 | - | entries: OrderedDict[str, dict[str, str]] = OrderedDict() | |
| 340 | - | if not paths.session_index_path.exists(): | |
| 341 | - | return entries | |
| 342 | - | ||
| 343 | - | for line in read_text(paths.session_index_path).splitlines(): | |
| 344 | - | if not line.strip(): | |
| 345 | - | continue | |
| 346 | - | entry = json.loads(line) | |
| 347 | - | thread_id = str(entry.get("id") or "").strip() | |
| 348 | - | if not thread_id: | |
| 349 | - | continue | |
| 350 | - | entries[thread_id] = { | |
| 351 | - | "id": thread_id, | |
| 352 | - | "thread_name": str(entry.get("thread_name") or thread_id), | |
| 353 | - | "updated_at": str(entry.get("updated_at") or ""), | |
| 354 | - | } | |
| 355 | - | return entries | |
| 356 | - | ||
| 357 | - | ||
| 358 | - | def write_session_index(paths: Paths, entries: list[dict[str, str]]) -> None: | |
| 359 | - | lines = [json.dumps(entry, ensure_ascii=False, separators=(",", ":")) for entry in entries] | |
| 360 | - | content = "\n".join(lines) | |
| 361 | - | if content: | |
| 362 | - | content += "\n" | |
| 363 | - | write_text_exact(paths.session_index_path, content) | |
| 364 | - | ||
| 365 | - | ||
| 366 | - | def iso_utc_from_unix(timestamp: int) -> str: | |
| 367 | - | return datetime.fromtimestamp(timestamp, tz=UTC).isoformat().replace("+00:00", "Z") | |
| 368 | - | ||
| 369 | - | ||
| 370 | - | def parse_index_timestamp(value: str) -> datetime: | |
| 371 | - | if not value: | |
| 372 | - | return datetime.fromtimestamp(0, tz=UTC) | |
| 373 | - | normalized = value.replace("Z", "+00:00") | |
| 374 | - | parsed = datetime.fromisoformat(normalized) | |
| 375 | - | if parsed.tzinfo is None: | |
| 376 | - | return parsed.replace(tzinfo=UTC) | |
| 377 | - | return parsed.astimezone(UTC) | |
| 378 | - | ||
| 379 | - | ||
| 380 | - | def snapshot_metadata(paths: Paths, backup_path: Path) -> None: | |
| 381 | - | if paths.session_index_path.exists(): | |
| 382 | - | write_text_exact(session_index_backup_path(backup_path), read_text_exact(paths.session_index_path)) | |
| 383 | - | ||
| 384 | - | items: list[dict[str, str]] = [] | |
| 385 | - | for path in iter_session_paths(paths): | |
| 386 | - | with path.open("r", encoding="utf-8", newline="") as handle: | |
| 387 | - | first_line = handle.readline().rstrip("\r\n") | |
| 388 | - | if not first_line: | |
| 389 | - | continue | |
| 390 | - | ||
| 391 | - | try: | |
| 392 | - | relative_path = path.relative_to(paths.codex_home) | |
| 393 | - | except ValueError: | |
| 394 | - | relative_path = path | |
| 395 | - | ||
| 396 | - | items.append({"path": str(relative_path), "first_line": first_line}) | |
| 397 | - | ||
| 398 | - | write_text_exact( | |
| 399 | - | session_meta_backup_path(backup_path), | |
| 400 | - | json.dumps(items, ensure_ascii=False, indent=2) + "\n", | |
| 401 | - | ) | |
| 402 | - | ||
| 403 | - | ||
| 404 | - | def restore_metadata(paths: Paths, backup_path: Path) -> dict[str, object]: | |
| 405 | - | started_at = time.monotonic() | |
| 406 | - | session_index_restored = False | |
| 407 | - | session_files_restored = 0 | |
| 408 | - | ||
| 409 | - | index_backup = session_index_backup_path(backup_path) | |
| 410 | - | if index_backup.exists(): | |
| 411 | - | write_text_exact(paths.session_index_path, read_text_exact(index_backup)) | |
| 412 | - | session_index_restored = True | |
| 413 | - | ||
| 414 | - | meta_backup = session_meta_backup_path(backup_path) | |
| 415 | - | if meta_backup.exists(): | |
| 416 | - | for item in json.loads(read_text(meta_backup)): | |
| 417 | - | raw_path = Path(item["path"]) | |
| 418 | - | path = raw_path if raw_path.is_absolute() else paths.codex_home / raw_path | |
| 419 | - | if not path.exists(): | |
| 420 | - | continue | |
| 421 | - | # 只恢复首行 session_meta,后面的对话内容保持原文件不动。 | |
| 422 | - | replace_first_line(path, str(item["first_line"])) | |
| 423 | - | session_files_restored += 1 | |
| 424 | - | ||
| 425 | - | return { | |
| 426 | - | "session_index_restored": session_index_restored, | |
| 427 | - | "session_files_restored": session_files_restored, | |
| 428 | - | "duration_ms": elapsed_ms(started_at), | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | ||
| 432 | - | def rebuild_session_index(paths: Paths, conn: sqlite3.Connection) -> dict[str, int]: | |
| 433 | - | started_at = time.monotonic() | |
| 434 | - | existing_entries = read_session_index(paths) | |
| 435 | - | columns = get_thread_columns(conn) | |
| 436 | - | select_parts = ["id"] | |
| 437 | - | if "title" in columns: | |
| 438 | - | select_parts.append("title") | |
| 439 | - | if "updated_at" in columns: | |
| 440 | - | select_parts.append("updated_at") | |
| 441 | - | where_sql = "WHERE archived = 0" if "archived" in columns else "" | |
| 442 | - | db_rows = conn.execute( | |
| 443 | - | f""" | |
| 444 | - | SELECT {", ".join(select_parts)} | |
| 445 | - | FROM threads | |
| 446 | - | {where_sql} | |
| 447 | - | ORDER BY id ASC | |
| 448 | - | """ | |
| 1200 further changed lines not shown | |||
The check that tells the two apart
fail→pass·tests/test_sync_backend.py::SyncBackendTests::test_resolve_paths_prefers_modern_sqlite_state_directory
fail→pass·tests/test_sync_backend.py::SyncBackendTests::test_session_file_without_model_is_current_when_provider_matches
Check file tests/test_sync_backend.py, taken without changes from the fix and copied onto the older code, so the exact same check runs against both versions.
Origin and history
The code before itde2ba346eb56f7e56e34bab358fee5a8ae4dbc98
Broken version dated2026-04-27
Modulesync_backend
Units changedget_status, resolve_paths, sync_session_records, to_json
Fingerprinta596badc8f7b2677
Checked2026-08-18 by goldset/0.1
Every field above is generated by our program. None of it is written by hand.