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.
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/bin/cdx +20 -0
- package/changelogs/CHANGELOGS_0_1_1.md +98 -0
- package/changelogs/CHANGELOGS_0_2_0.md +68 -0
- package/changelogs/CHANGELOGS_0_2_1.md +29 -0
- package/package.json +44 -0
- package/src/__init__.py +17 -0
- package/src/claude_refresh.py +72 -0
- package/src/claude_usage.py +84 -0
- package/src/cli.py +188 -0
- package/src/cli_commands.py +369 -0
- package/src/cli_render.py +131 -0
- package/src/config.py +8 -0
- package/src/errors.py +4 -0
- package/src/health.py +125 -0
- package/src/notify.py +138 -0
- package/src/provider_runtime.py +290 -0
- package/src/repair.py +121 -0
- package/src/session_service.py +563 -0
- package/src/session_store.py +244 -0
- package/src/status_source.py +572 -0
- package/src/status_view.py +270 -0
|
@@ -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
|
+
}
|