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

4949
5050 def resolve_paths(codex_home: str | None) -> Paths:
5151 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

failpass·tests/test_sync_backend.py::SyncBackendTests::test_resolve_paths_prefers_modern_sqlite_state_directory
failpass·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.