cdx-manager 0.2.1

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,563 @@
1
+ import os
2
+ import shutil
3
+ import json
4
+ import base64
5
+ import tempfile
6
+ from datetime import datetime, timezone
7
+ from urllib.parse import quote
8
+
9
+ from .config import get_cdx_home
10
+ from .errors import CdxError
11
+ from .session_store import create_session_store
12
+ from .status_source import find_latest_status_artifact
13
+
14
+ DEFAULT_PROVIDER = "codex"
15
+ ALLOWED_PROVIDERS = {"codex", "claude"}
16
+ RESERVED_SESSION_NAMES = {
17
+ "add",
18
+ "clean",
19
+ "cp",
20
+ "doctor",
21
+ "help",
22
+ "login",
23
+ "logout",
24
+ "mv",
25
+ "notify",
26
+ "repair",
27
+ "ren",
28
+ "rename",
29
+ "rmv",
30
+ "status",
31
+ "version",
32
+ "--help",
33
+ "-h",
34
+ "--version",
35
+ "-v",
36
+ }
37
+
38
+
39
+ def _encode(name):
40
+ return quote(name, safe="")
41
+
42
+
43
+ def _local_now_iso():
44
+ return datetime.now().astimezone().isoformat()
45
+
46
+
47
+ def _to_local_iso(value):
48
+ if not value:
49
+ return value
50
+ try:
51
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
52
+ except (TypeError, ValueError):
53
+ return value
54
+ if parsed.tzinfo is None:
55
+ parsed = parsed.replace(tzinfo=timezone.utc)
56
+ return parsed.astimezone().isoformat()
57
+
58
+
59
+ def _normalize_status_payload(payload=None):
60
+ if payload is None:
61
+ payload = {}
62
+ now = _local_now_iso()
63
+ return {
64
+ "usage_pct": payload.get("usage_pct"),
65
+ "remaining_5h_pct": payload.get("remaining_5h_pct"),
66
+ "remaining_week_pct": payload.get("remaining_week_pct"),
67
+ "credits": payload.get("credits"),
68
+ "reset_5h_at": payload.get("reset_5h_at"),
69
+ "reset_week_at": payload.get("reset_week_at"),
70
+ "reset_at": payload.get("reset_at") or payload.get("reset_week_at") or payload.get("reset_5h_at"),
71
+ "updated_at": _to_local_iso(payload.get("updated_at") or payload.get("captured_at") or now),
72
+ "raw_status_text": payload.get("raw_status_text"),
73
+ "source_ref": payload.get("source_ref"),
74
+ }
75
+
76
+
77
+ def _parse_status_timestamp(value):
78
+ if not value:
79
+ return None
80
+ try:
81
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
82
+ except (TypeError, ValueError):
83
+ return None
84
+
85
+
86
+ def _is_status_newer(candidate, current):
87
+ if not candidate:
88
+ return False
89
+ if not current:
90
+ return True
91
+ candidate_at = _parse_status_timestamp(candidate.get("updated_at"))
92
+ current_at = _parse_status_timestamp(current.get("updated_at"))
93
+ if candidate_at and current_at:
94
+ return candidate_at > current_at
95
+ if candidate_at:
96
+ return True
97
+ return False
98
+
99
+
100
+ def _status_has_more_detail(candidate, current):
101
+ if not candidate:
102
+ return False
103
+ if not current:
104
+ return True
105
+
106
+ fields = [
107
+ "usage_pct",
108
+ "remaining_5h_pct",
109
+ "remaining_week_pct",
110
+ "credits",
111
+ "reset_5h_at",
112
+ "reset_week_at",
113
+ "reset_at",
114
+ "raw_status_text",
115
+ "source_ref",
116
+ ]
117
+ return any(current.get(field) is None and candidate.get(field) is not None for field in fields)
118
+
119
+
120
+ def _merge_status_payload(current, candidate):
121
+ if not current:
122
+ return candidate
123
+ if not candidate:
124
+ return current
125
+
126
+ merged = dict(current)
127
+ for field in [
128
+ "usage_pct",
129
+ "remaining_5h_pct",
130
+ "remaining_week_pct",
131
+ "credits",
132
+ "reset_5h_at",
133
+ "reset_week_at",
134
+ "reset_at",
135
+ "raw_status_text",
136
+ "source_ref",
137
+ ]:
138
+ if merged.get(field) is None and candidate.get(field) is not None:
139
+ merged[field] = candidate[field]
140
+
141
+ merged["updated_at"] = candidate.get("updated_at") or current.get("updated_at")
142
+ return merged
143
+
144
+
145
+ def _compute_available_pct(status):
146
+ if not status:
147
+ return None
148
+ values = [
149
+ status.get("remaining_5h_pct"),
150
+ status.get("remaining_week_pct"),
151
+ ]
152
+ values = [value for value in values if value is not None]
153
+ if not values:
154
+ return None
155
+ return min(values)
156
+
157
+
158
+ def _is_low_confidence_status_source(status):
159
+ if not status:
160
+ return False
161
+ source_ref = str(status.get("source_ref") or "").replace(os.sep, "/")
162
+ return "/sessions/" in source_ref and "/rollout" in source_ref
163
+
164
+
165
+ def _decode_jwt_claims(token):
166
+ if not token or "." not in str(token):
167
+ return {}
168
+ parts = str(token).split(".")
169
+ if len(parts) < 2:
170
+ return {}
171
+ payload = parts[1]
172
+ padding = "=" * (-len(payload) % 4)
173
+ try:
174
+ decoded = base64.urlsafe_b64decode(payload + padding)
175
+ return json.loads(decoded.decode("utf-8"))
176
+ except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
177
+ return {}
178
+
179
+
180
+ def _read_expected_account_email(auth_home):
181
+ auth_path = os.path.join(auth_home, "auth.json")
182
+ try:
183
+ with open(auth_path, "r", encoding="utf-8") as handle:
184
+ auth = json.load(handle)
185
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
186
+ return None
187
+
188
+ tokens = auth.get("tokens") or {}
189
+ for token_name in ("id_token", "access_token"):
190
+ claims = _decode_jwt_claims(tokens.get(token_name))
191
+ email = claims.get("email")
192
+ if not email and token_name == "access_token":
193
+ profile = claims.get("https://api.openai.com/profile") or {}
194
+ email = profile.get("email")
195
+ if email:
196
+ return str(email).strip().lower()
197
+ return None
198
+
199
+
200
+ def create_session_service(options=None):
201
+ if options is None:
202
+ options = {}
203
+ env = options.get("env", os.environ)
204
+ base_dir = options.get("base_dir") or get_cdx_home(env)
205
+ store = options.get("store") or create_session_store(base_dir)
206
+
207
+ def _get_session_root(name):
208
+ return os.path.join(base_dir, "profiles", _encode(name))
209
+
210
+ def _get_session_auth_home(name, provider):
211
+ root = _get_session_root(name)
212
+ if provider == "claude":
213
+ return os.path.join(root, "claude-home")
214
+ return root
215
+
216
+ def _normalize_provider(provider):
217
+ value = provider or DEFAULT_PROVIDER
218
+ if value not in ALLOWED_PROVIDERS:
219
+ raise CdxError(f"Unsupported provider: {value}")
220
+ return value
221
+
222
+ def _validate_new_session_name(name):
223
+ if not name:
224
+ raise CdxError("Session name is required")
225
+ if name in RESERVED_SESSION_NAMES:
226
+ raise CdxError(f"Session name is reserved: {name}")
227
+
228
+ def create_session(name, provider=DEFAULT_PROVIDER):
229
+ _validate_new_session_name(name)
230
+ normalized_provider = _normalize_provider(provider)
231
+ session_root = _get_session_root(name)
232
+ auth_home = _get_session_auth_home(name, normalized_provider)
233
+ os.makedirs(auth_home, exist_ok=True)
234
+ now = _local_now_iso()
235
+ session = {
236
+ "name": name,
237
+ "provider": normalized_provider,
238
+ "sessionRoot": session_root,
239
+ "authHome": auth_home,
240
+ "createdAt": now,
241
+ "updatedAt": now,
242
+ "lastLaunchedAt": None,
243
+ "lastStatusAt": None,
244
+ "lastStatus": None,
245
+ "auth": {
246
+ "status": "unknown",
247
+ "lastCheckedAt": None,
248
+ "lastAuthenticatedAt": None,
249
+ "lastLoggedOutAt": None,
250
+ },
251
+ }
252
+ result = store["add_session"](session)
253
+ if not result["ok"]:
254
+ raise CdxError(f"Session already exists: {name}")
255
+ return result["session"]
256
+
257
+ def remove_session(name):
258
+ session = store["get_session"](name)
259
+ if not session:
260
+ raise CdxError(f"Unknown session: {name}")
261
+ session_root = session.get("sessionRoot") or _get_session_root(name)
262
+ quarantine_root = None
263
+ if os.path.exists(session_root):
264
+ profiles_dir = os.path.dirname(session_root)
265
+ os.makedirs(profiles_dir, exist_ok=True)
266
+ quarantine_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.remove.", dir=profiles_dir)
267
+ os.rmdir(quarantine_root)
268
+ os.rename(session_root, quarantine_root)
269
+ try:
270
+ removed = store["remove_session"](name)
271
+ except Exception:
272
+ if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
273
+ os.rename(quarantine_root, session_root)
274
+ raise
275
+ if not removed:
276
+ if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
277
+ os.rename(quarantine_root, session_root)
278
+ raise CdxError(f"Unknown session: {name}")
279
+ if quarantine_root:
280
+ try:
281
+ shutil.rmtree(quarantine_root)
282
+ except OSError as error:
283
+ raise CdxError(
284
+ f"Removed session {name}, but failed to delete archived profile {quarantine_root}: {error}"
285
+ ) from error
286
+ return removed
287
+
288
+ def copy_session(source_name, dest_name):
289
+ if source_name == dest_name:
290
+ raise CdxError("Source and destination session names must be different")
291
+ _validate_new_session_name(dest_name)
292
+ source = store["get_session"](source_name)
293
+ if not source:
294
+ raise CdxError(f"Unknown session: {source_name}")
295
+ existing = store["get_session"](dest_name)
296
+ overwritten = False
297
+ source_root = source.get("sessionRoot") or _get_session_root(source_name)
298
+ dest_root = _get_session_root(dest_name)
299
+ if not existing and os.path.exists(dest_root):
300
+ raise CdxError(f"Session profile already exists: {dest_name}")
301
+ dest_auth_home = _get_session_auth_home(dest_name, source["provider"])
302
+ profiles_dir = os.path.dirname(dest_root)
303
+ os.makedirs(profiles_dir, exist_ok=True)
304
+ temp_parent = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.copy.", dir=profiles_dir)
305
+ temp_root = os.path.join(temp_parent, "profile")
306
+ backup_root = None
307
+ moved_temp = False
308
+ now = _local_now_iso()
309
+ replacement = {
310
+ "name": dest_name,
311
+ "provider": source["provider"],
312
+ "sessionRoot": dest_root,
313
+ "authHome": dest_auth_home,
314
+ "createdAt": now,
315
+ "updatedAt": now,
316
+ "lastLaunchedAt": None,
317
+ "lastStatusAt": None,
318
+ "lastStatus": None,
319
+ "auth": {
320
+ "status": "unknown",
321
+ "lastCheckedAt": None,
322
+ "lastAuthenticatedAt": None,
323
+ "lastLoggedOutAt": None,
324
+ },
325
+ }
326
+ try:
327
+ shutil.copytree(source_root, temp_root)
328
+ if existing:
329
+ backup_root = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.backup.", dir=profiles_dir)
330
+ os.rmdir(backup_root)
331
+ if os.path.exists(dest_root):
332
+ os.rename(dest_root, backup_root)
333
+ os.rename(temp_root, dest_root)
334
+ moved_temp = True
335
+ result = store["replace_session"](dest_name, replacement)
336
+ overwritten = bool(existing)
337
+ except Exception:
338
+ if moved_temp and os.path.exists(dest_root):
339
+ shutil.rmtree(dest_root, ignore_errors=True)
340
+ if backup_root and os.path.exists(backup_root) and not os.path.exists(dest_root):
341
+ os.rename(backup_root, dest_root)
342
+ raise
343
+ finally:
344
+ if backup_root and os.path.exists(backup_root):
345
+ shutil.rmtree(backup_root, ignore_errors=True)
346
+ shutil.rmtree(temp_parent, ignore_errors=True)
347
+ if not result["ok"]:
348
+ raise CdxError(f"Failed to create session: {dest_name}")
349
+ return {"session": result["session"], "overwritten": overwritten}
350
+
351
+ def rename_session(source_name, dest_name):
352
+ if source_name == dest_name:
353
+ raise CdxError("Source and destination session names must be different")
354
+ _validate_new_session_name(dest_name)
355
+ source = store["get_session"](source_name)
356
+ if not source:
357
+ raise CdxError(f"Unknown session: {source_name}")
358
+ if store["get_session"](dest_name):
359
+ raise CdxError(f"Session already exists: {dest_name}")
360
+
361
+ source_root = source.get("sessionRoot") or _get_session_root(source_name)
362
+ dest_root = _get_session_root(dest_name)
363
+ if os.path.exists(dest_root):
364
+ raise CdxError(f"Session profile already exists: {dest_name}")
365
+
366
+ if os.path.exists(source_root):
367
+ os.rename(source_root, dest_root)
368
+ moved_profile = True
369
+ else:
370
+ moved_profile = False
371
+
372
+ now = _local_now_iso()
373
+ try:
374
+ result = store["rename_session"](source_name, dest_name, lambda s: {
375
+ **s,
376
+ "name": dest_name,
377
+ "sessionRoot": dest_root,
378
+ "authHome": _get_session_auth_home(dest_name, s["provider"]),
379
+ "updatedAt": now,
380
+ })
381
+ except Exception:
382
+ if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
383
+ os.rename(dest_root, source_root)
384
+ raise
385
+
386
+ if not result["ok"]:
387
+ if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
388
+ os.rename(dest_root, source_root)
389
+ if result["reason"] == "exists":
390
+ raise CdxError(f"Session already exists: {dest_name}")
391
+ raise CdxError(f"Unknown session: {source_name}")
392
+ return result["session"]
393
+
394
+ def launch_session(name):
395
+ session = store["get_session"](name)
396
+ if not session:
397
+ raise CdxError(f"Unknown session: {name}")
398
+ state = store["read_session_state"](name)
399
+ if not state:
400
+ raise CdxError(f"Session state missing for {name}. Reconnect required.")
401
+ now = _local_now_iso()
402
+ store["write_session_state"](name, {**state, "rehydratedAt": now})
403
+ return store["update_session"](name, lambda s: {
404
+ **s, "updatedAt": now, "lastLaunchedAt": now
405
+ })
406
+
407
+ def ensure_session_state(name):
408
+ session = store["get_session"](name)
409
+ if not session:
410
+ raise CdxError(f"Unknown session: {name}")
411
+ state = store["read_session_state"](name)
412
+ if state:
413
+ return state
414
+ repaired = {
415
+ "provider": session["provider"],
416
+ "status": "ready",
417
+ "rehydratedAt": None,
418
+ }
419
+ store["write_session_state"](name, repaired)
420
+ return repaired
421
+
422
+ def list_sessions():
423
+ return store["list_sessions"]()
424
+
425
+ def get_session(name):
426
+ return store["get_session"](name)
427
+
428
+ def record_status(name, payload):
429
+ normalized = _normalize_status_payload(payload)
430
+ updated = store["update_session"](name, lambda s: {
431
+ **s,
432
+ "lastStatus": normalized,
433
+ "lastStatusAt": normalized["updated_at"],
434
+ })
435
+ if not updated:
436
+ raise CdxError(f"Unknown session: {name}")
437
+ return updated
438
+
439
+ def _resolve_session_status(session):
440
+ current_status = session.get("lastStatus")
441
+ source_root = session.get("authHome") or _get_session_auth_home(
442
+ session["name"], session["provider"]
443
+ )
444
+ expected_account_email = (
445
+ _read_expected_account_email(source_root)
446
+ if session["provider"] == "codex"
447
+ else None
448
+ )
449
+ artifact = find_latest_status_artifact(
450
+ source_root,
451
+ session["provider"],
452
+ expected_account_email=expected_account_email,
453
+ )
454
+ if not artifact:
455
+ if _is_low_confidence_status_source(current_status):
456
+ return None
457
+ return current_status
458
+ resolved = _normalize_status_payload({
459
+ "usage_pct": artifact.get("usage_pct"),
460
+ "remaining_5h_pct": artifact.get("remaining_5h_pct"),
461
+ "remaining_week_pct": artifact.get("remaining_week_pct"),
462
+ "credits": artifact.get("credits"),
463
+ "reset_5h_at": artifact.get("reset_5h_at"),
464
+ "reset_week_at": artifact.get("reset_week_at"),
465
+ "reset_at": artifact.get("reset_at"),
466
+ "updated_at": artifact.get("updated_at"),
467
+ "raw_status_text": artifact.get("raw_status_text"),
468
+ "source_ref": artifact.get("source_ref"),
469
+ })
470
+ if _is_low_confidence_status_source(current_status) and not _is_low_confidence_status_source(resolved):
471
+ record_status(session["name"], resolved)
472
+ return resolved
473
+ if _is_status_newer(resolved, current_status):
474
+ record_status(session["name"], resolved)
475
+ return resolved
476
+ if _status_has_more_detail(resolved, current_status):
477
+ merged = _merge_status_payload(current_status, resolved)
478
+ record_status(session["name"], merged)
479
+ return merged
480
+ return current_status or resolved
481
+
482
+ def update_auth_state(name, updater):
483
+ now = _local_now_iso()
484
+ updated = store["update_session"](name, lambda s: {
485
+ **s,
486
+ "updatedAt": now,
487
+ "auth": updater(s.get("auth") or {}),
488
+ })
489
+ if not updated:
490
+ raise CdxError(f"Unknown session: {name}")
491
+ return updated
492
+
493
+ def get_status_rows():
494
+ sessions = list_sessions()
495
+ resolved = []
496
+ for s in sessions:
497
+ status = _resolve_session_status(s)
498
+ resolved.append({
499
+ **s,
500
+ "lastStatus": status,
501
+ "lastStatusAt": (status and status.get("updated_at")) or s.get("lastStatusAt"),
502
+ })
503
+
504
+ def sort_key(s):
505
+ at = s.get("lastStatusAt") or ""
506
+ return ("" if at else "\xff", at, s["name"])
507
+
508
+ resolved.sort(key=sort_key)
509
+ resolved.reverse()
510
+
511
+ rows = []
512
+ for s in resolved:
513
+ status = s.get("lastStatus")
514
+ rows.append({
515
+ "session_name": s["name"],
516
+ "provider": s["provider"],
517
+ "auth_home": s.get("authHome") or _get_session_auth_home(s["name"], s["provider"]),
518
+ "remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
519
+ "remaining_week_pct": status.get("remaining_week_pct") if status else None,
520
+ "credits": status.get("credits") if status else None,
521
+ "available_pct": _compute_available_pct(status),
522
+ "reset_5h_at": status.get("reset_5h_at") if status else None,
523
+ "reset_week_at": status.get("reset_week_at") if status else None,
524
+ "reset_at": status.get("reset_at") if status else None,
525
+ "updated_at": _to_local_iso(s.get("lastStatusAt")),
526
+ })
527
+ return rows
528
+
529
+ def format_list_rows():
530
+ sessions = list_sessions()
531
+ providers = {s["provider"] for s in sessions}
532
+ has_multiple = len(providers) > 1
533
+ return [{
534
+ "name": s["name"],
535
+ "provider": s["provider"] if has_multiple else None,
536
+ "status": s.get("lastStatus"),
537
+ "updated_at": _to_local_iso(s.get("updatedAt")),
538
+ } for s in sessions]
539
+
540
+ def get_session_auth_home(name, provider):
541
+ return _get_session_auth_home(name, provider)
542
+
543
+ def get_session_root(name):
544
+ return _get_session_root(name)
545
+
546
+ return {
547
+ "create_session": create_session,
548
+ "remove_session": remove_session,
549
+ "copy_session": copy_session,
550
+ "rename_session": rename_session,
551
+ "launch_session": launch_session,
552
+ "ensure_session_state": ensure_session_state,
553
+ "list_sessions": list_sessions,
554
+ "get_session": get_session,
555
+ "record_status": record_status,
556
+ "update_auth_state": update_auth_state,
557
+ "get_status_rows": get_status_rows,
558
+ "format_list_rows": format_list_rows,
559
+ "get_session_auth_home": get_session_auth_home,
560
+ "get_session_root": get_session_root,
561
+ "base_dir": base_dir,
562
+ "normalize_provider": _normalize_provider,
563
+ }