-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsync_backend.py
More file actions
321 lines (262 loc) · 10.6 KB
/
Copy pathsync_backend.py
File metadata and controls
321 lines (262 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
from __future__ import annotations
import argparse
import json
import re
import sqlite3
from collections import OrderedDict
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
def default_codex_home() -> Path:
return Path.home() / ".codex"
@dataclass
class Paths:
codex_home: Path
config_path: Path
auth_path: Path
db_path: Path
backup_dir: Path
def resolve_paths(codex_home: str | None) -> Paths:
home = Path(codex_home).expanduser() if codex_home else default_codex_home()
return Paths(
codex_home=home,
config_path=home / "config.toml",
auth_path=home / "auth.json",
db_path=home / "state_5.sqlite",
backup_dir=home / "history_sync_backups",
)
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def parse_current_provider(config_text: str) -> str | None:
match = re.search(r'(?m)^\s*model_provider\s*=\s*"([^"]+)"', config_text)
return match.group(1) if match else None
def parse_current_model(config_text: str) -> str | None:
match = re.search(r'(?m)^\s*model\s*=\s*"([^"]+)"', config_text)
return match.group(1) if match else None
def read_auth_mode(paths: Paths) -> str | None:
if not paths.auth_path.exists():
return None
try:
payload = json.loads(read_text(paths.auth_path))
except (OSError, json.JSONDecodeError):
return None
auth_mode = payload.get("auth_mode")
return auth_mode if isinstance(auth_mode, str) and auth_mode.strip() else None
def infer_provider_from_auth(auth_mode: str | None) -> tuple[str | None, str | None]:
if not auth_mode:
return None, None
normalized = auth_mode.strip().lower()
if normalized in {"chatgpt", "openai", "openai_api_key", "api_key"}:
return "openai", f"auth_mode:{normalized}"
return None, None
def connect_db(path: Path, readonly: bool = False) -> sqlite3.Connection:
if readonly:
return sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=30)
conn = sqlite3.connect(str(path), timeout=30)
conn.execute("PRAGMA busy_timeout = 30000")
return conn
def ensure_environment(paths: Paths) -> None:
if not paths.config_path.exists():
raise RuntimeError(f"Missing config file: {paths.config_path}")
if not paths.db_path.exists():
raise RuntimeError(f"Missing database file: {paths.db_path}")
def query_provider_counts(conn: sqlite3.Connection) -> OrderedDict[str, int]:
counts = OrderedDict()
for provider, count in conn.execute(
"""
SELECT model_provider, COUNT(*)
FROM threads
GROUP BY model_provider
ORDER BY COUNT(*) DESC, model_provider ASC
"""
):
counts[provider or "(empty)"] = count
return counts
def infer_provider_from_threads(conn: sqlite3.Connection, current_model: str | None) -> tuple[str | None, str | None]:
if current_model:
row = conn.execute(
"""
SELECT model_provider
FROM threads
WHERE model = ? AND model_provider <> ''
ORDER BY updated_at DESC
LIMIT 1
""",
(current_model,),
).fetchone()
if row and row[0]:
return row[0], "latest_thread_for_model"
row = conn.execute(
"""
SELECT model_provider
FROM threads
WHERE model_provider <> ''
ORDER BY updated_at DESC
LIMIT 1
"""
).fetchone()
if row and row[0]:
return row[0], "latest_thread"
return None, None
def list_backups(paths: Paths, limit: int = 20) -> list[dict[str, str]]:
if not paths.backup_dir.exists():
return []
files = sorted(
paths.backup_dir.glob("state_5.sqlite.*.bak"),
key=lambda item: item.stat().st_mtime,
reverse=True,
)
output = []
for item in files[:limit]:
output.append(
{
"name": item.name,
"path": str(item),
"modified_at": datetime.fromtimestamp(item.stat().st_mtime).isoformat(timespec="seconds"),
}
)
return output
def get_status(paths: Paths) -> dict[str, object]:
ensure_environment(paths)
config_text = read_text(paths.config_path)
current_provider = parse_current_provider(config_text)
current_model = parse_current_model(config_text)
provider_source = "config.toml:model_provider" if current_provider else None
auth_mode = read_auth_mode(paths)
with connect_db(paths.db_path, readonly=True) as conn:
if not current_provider:
current_provider, provider_source = infer_provider_from_auth(auth_mode)
if not current_provider:
current_provider, provider_source = infer_provider_from_threads(conn, current_model)
if not current_provider:
raise RuntimeError(
"Could not determine the current provider from config.toml, auth.json, or recent threads."
)
counts = query_provider_counts(conn)
total_threads = conn.execute("SELECT COUNT(*) FROM threads").fetchone()[0]
moved_if_sync = conn.execute(
"SELECT COUNT(*) FROM threads WHERE model_provider <> ?",
(current_provider,),
).fetchone()[0]
return {
"codex_home": str(paths.codex_home),
"config_path": str(paths.config_path),
"db_path": str(paths.db_path),
"backup_dir": str(paths.backup_dir),
"current_provider": current_provider,
"current_provider_source": provider_source,
"current_model": current_model,
"auth_mode": auth_mode,
"total_threads": total_threads,
"movable_threads": moved_if_sync,
"provider_counts": [{"provider": key, "count": value} for key, value in counts.items()],
"backups": list_backups(paths),
}
def make_backup(paths: Paths, label: str) -> Path:
paths.backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = paths.backup_dir / f"state_5.sqlite.{label}.{timestamp}.bak"
with connect_db(paths.db_path, readonly=True) as source, connect_db(backup_path, readonly=False) as target:
source.backup(target)
return backup_path
def checkpoint(conn: sqlite3.Connection) -> tuple[int, int, int]:
row = conn.execute("PRAGMA wal_checkpoint(FULL)").fetchone()
return int(row[0]), int(row[1]), int(row[2])
def sync_to_current_provider(paths: Paths) -> dict[str, object]:
status_before = get_status(paths)
current_provider = status_before["current_provider"]
backup_path = make_backup(paths, "pre-sync")
with connect_db(paths.db_path, readonly=False) as conn:
before_counts = query_provider_counts(conn)
updated_rows = conn.execute(
"UPDATE threads SET model_provider = ? WHERE model_provider <> ?",
(current_provider, current_provider),
).rowcount
conn.commit()
checkpoint_result = checkpoint(conn)
after_counts = query_provider_counts(conn)
return {
"action": "sync",
"current_provider": current_provider,
"updated_rows": updated_rows,
"backup_path": str(backup_path),
"before_counts": [{"provider": key, "count": value} for key, value in before_counts.items()],
"after_counts": [{"provider": key, "count": value} for key, value in after_counts.items()],
"checkpoint": {
"busy": checkpoint_result[0],
"log_frames": checkpoint_result[1],
"checkpointed_frames": checkpoint_result[2],
},
}
def resolve_backup(paths: Paths, requested_path: str | None) -> Path:
if requested_path:
backup = Path(requested_path).expanduser()
else:
backups = list_backups(paths, limit=1)
if not backups:
raise RuntimeError("No backup files were found.")
backup = Path(backups[0]["path"])
if not backup.exists():
raise RuntimeError(f"Backup file does not exist: {backup}")
return backup
def restore_backup(paths: Paths, backup_path: str | None) -> dict[str, object]:
ensure_environment(paths)
chosen_backup = resolve_backup(paths, backup_path)
restore_snapshot = make_backup(paths, "pre-restore")
with connect_db(chosen_backup, readonly=True) as source, connect_db(paths.db_path, readonly=False) as target:
source.backup(target)
checkpoint_result = checkpoint(target)
status_after = get_status(paths)
return {
"action": "restore",
"restored_from": str(chosen_backup),
"safety_backup": str(restore_snapshot),
"checkpoint": {
"busy": checkpoint_result[0],
"log_frames": checkpoint_result[1],
"checkpointed_frames": checkpoint_result[2],
},
"status": status_after,
}
def to_json(payload: dict[str, object]) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2)
def main() -> int:
parser = argparse.ArgumentParser(description="Codex history sync helper")
parser.add_argument("--codex-home", help="Override Codex home directory")
parser.add_argument("--json", action="store_true", help="Emit JSON output")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("status", help="Show current provider/thread status")
subparsers.add_parser("sync", help="Move all thread providers to the current provider")
restore_parser = subparsers.add_parser("restore", help="Restore from a backup")
restore_parser.add_argument("--backup", help="Backup file path; newest backup is used when omitted")
subparsers.add_parser("backup", help="Create a manual backup")
args = parser.parse_args()
paths = resolve_paths(args.codex_home)
try:
if args.command == "status":
payload = get_status(paths)
elif args.command == "sync":
payload = sync_to_current_provider(paths)
elif args.command == "restore":
payload = restore_backup(paths, args.backup)
elif args.command == "backup":
ensure_environment(paths)
payload = {"action": "backup", "backup_path": str(make_backup(paths, "manual"))}
else:
raise RuntimeError(f"Unsupported command: {args.command}")
except Exception as exc:
error_payload = {"ok": False, "error": str(exc)}
if args.json:
print(to_json(error_payload))
else:
print(error_payload["error"])
return 1
if isinstance(payload, dict):
payload["ok"] = True
if args.json:
print(to_json(payload))
else:
print(payload)
return 0
if __name__ == "__main__":
raise SystemExit(main())