cdx-manager 0.12.4 → 0.14.0
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/README.md +132 -3
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +274 -33
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +177 -2
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +562 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +681 -0
- package/src/config.py +41 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_runtime.py +46 -11
- package/src/run_command.py +112 -4
- package/src/run_registry.py +53 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +574 -1426
- package/src/session_status.py +534 -0
- package/src/status_view.py +15 -34
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Status domain: reading, caching, merging and reporting session status.
|
|
2
|
+
|
|
3
|
+
Split out of session_service.py along one of the two seams the req_027
|
|
4
|
+
coupling measurement supports. Moved verbatim; re-exported by session_service.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from .claude_usage import _decode_jwt_claims
|
|
13
|
+
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX, get_cdx_home
|
|
14
|
+
from .errors import CdxError
|
|
15
|
+
from .session_helpers import (
|
|
16
|
+
_get_global_codex_home,
|
|
17
|
+
_get_session_auth_home,
|
|
18
|
+
_local_now_iso,
|
|
19
|
+
_session_runtime,
|
|
20
|
+
list_sessions,
|
|
21
|
+
)
|
|
22
|
+
from .status_source import find_latest_status_artifact
|
|
23
|
+
|
|
24
|
+
STATUS_CACHE_TTL_SECONDS = 60
|
|
25
|
+
|
|
26
|
+
CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
|
|
27
|
+
|
|
28
|
+
CODEX_STATUS_CACHE_TTL_SECONDS = 5 * 60
|
|
29
|
+
|
|
30
|
+
MAX_STATUS_WORKERS = 8
|
|
31
|
+
|
|
32
|
+
def _to_local_iso(value):
|
|
33
|
+
if not value:
|
|
34
|
+
return value
|
|
35
|
+
try:
|
|
36
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return value
|
|
39
|
+
if parsed.tzinfo is None:
|
|
40
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
41
|
+
return parsed.astimezone().isoformat()
|
|
42
|
+
|
|
43
|
+
def _row_priority(session):
|
|
44
|
+
launch = session.get("launch") or {}
|
|
45
|
+
try:
|
|
46
|
+
return int(launch.get("priority") or 0)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
def _row_reasoning_effort(session):
|
|
51
|
+
launch = session.get("launch") or {}
|
|
52
|
+
return (
|
|
53
|
+
launch.get("reasoning_effort")
|
|
54
|
+
or launch.get("reasoningEffort")
|
|
55
|
+
or launch.get("power")
|
|
56
|
+
or ("low" if launch.get("fast") is True and launch.get("fastMode") != "service_tier" else None)
|
|
57
|
+
or "low"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _normalize_pct_value(value):
|
|
61
|
+
if value is None:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return max(0, min(100, round(float(value))))
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def _normalize_status_payload(payload=None):
|
|
69
|
+
if payload is None:
|
|
70
|
+
payload = {}
|
|
71
|
+
now = _local_now_iso()
|
|
72
|
+
return {
|
|
73
|
+
"usage_pct": _normalize_pct_value(payload.get("usage_pct")),
|
|
74
|
+
"remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
|
|
75
|
+
"remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
|
|
76
|
+
"credits": payload.get("credits"),
|
|
77
|
+
"reset_credits_available": payload.get("reset_credits_available"),
|
|
78
|
+
"reset_credits": payload.get("reset_credits"),
|
|
79
|
+
"reset_5h_at": payload.get("reset_5h_at"),
|
|
80
|
+
"reset_week_at": payload.get("reset_week_at"),
|
|
81
|
+
"reset_at": payload.get("reset_at") or payload.get("reset_week_at") or payload.get("reset_5h_at"),
|
|
82
|
+
"updated_at": _to_local_iso(payload.get("updated_at") or payload.get("captured_at") or now),
|
|
83
|
+
"raw_status_text": payload.get("raw_status_text"),
|
|
84
|
+
"source_ref": payload.get("source_ref"),
|
|
85
|
+
"structured": bool(payload.get("structured")),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def _parse_status_timestamp(value):
|
|
89
|
+
if not value:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def _status_cache_ttl_seconds(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
97
|
+
if ttl_seconds == STATUS_CACHE_TTL_SECONDS:
|
|
98
|
+
if session.get("provider") == PROVIDER_CLAUDE:
|
|
99
|
+
return CLAUDE_STATUS_CACHE_TTL_SECONDS
|
|
100
|
+
if session.get("provider") == PROVIDER_CODEX:
|
|
101
|
+
return CODEX_STATUS_CACHE_TTL_SECONDS
|
|
102
|
+
return ttl_seconds
|
|
103
|
+
|
|
104
|
+
def _is_status_cache_fresh(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
105
|
+
status = session.get("lastStatus") or {}
|
|
106
|
+
if _is_low_confidence_status_source(status):
|
|
107
|
+
return False
|
|
108
|
+
updated_at = _parse_status_timestamp(status.get("updated_at") or session.get("lastStatusAt"))
|
|
109
|
+
if not updated_at:
|
|
110
|
+
return False
|
|
111
|
+
now = datetime.now(timezone.utc).astimezone()
|
|
112
|
+
ttl_seconds = _status_cache_ttl_seconds(session, ttl_seconds)
|
|
113
|
+
return (now - updated_at.astimezone(now.tzinfo)).total_seconds() < ttl_seconds
|
|
114
|
+
|
|
115
|
+
def _is_status_newer(candidate, current):
|
|
116
|
+
if not candidate:
|
|
117
|
+
return False
|
|
118
|
+
if not current:
|
|
119
|
+
return True
|
|
120
|
+
candidate_at = _parse_status_timestamp(candidate.get("updated_at"))
|
|
121
|
+
current_at = _parse_status_timestamp(current.get("updated_at"))
|
|
122
|
+
if candidate_at and current_at:
|
|
123
|
+
return candidate_at > current_at
|
|
124
|
+
if candidate_at:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def _status_has_more_detail(candidate, current):
|
|
129
|
+
if not candidate:
|
|
130
|
+
return False
|
|
131
|
+
if not current:
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
fields = [
|
|
135
|
+
"usage_pct",
|
|
136
|
+
"remaining_5h_pct",
|
|
137
|
+
"remaining_week_pct",
|
|
138
|
+
"credits",
|
|
139
|
+
"reset_credits_available",
|
|
140
|
+
"reset_credits",
|
|
141
|
+
"reset_5h_at",
|
|
142
|
+
"reset_week_at",
|
|
143
|
+
"reset_at",
|
|
144
|
+
"raw_status_text",
|
|
145
|
+
"source_ref",
|
|
146
|
+
]
|
|
147
|
+
return any(current.get(field) is None and candidate.get(field) is not None for field in fields)
|
|
148
|
+
|
|
149
|
+
def _merge_status_payload(current, candidate):
|
|
150
|
+
if not current:
|
|
151
|
+
return candidate
|
|
152
|
+
if not candidate:
|
|
153
|
+
return current
|
|
154
|
+
|
|
155
|
+
merged = dict(current)
|
|
156
|
+
for field in [
|
|
157
|
+
"usage_pct",
|
|
158
|
+
"remaining_5h_pct",
|
|
159
|
+
"remaining_week_pct",
|
|
160
|
+
"credits",
|
|
161
|
+
"reset_credits_available",
|
|
162
|
+
"reset_credits",
|
|
163
|
+
"reset_5h_at",
|
|
164
|
+
"reset_week_at",
|
|
165
|
+
"reset_at",
|
|
166
|
+
"raw_status_text",
|
|
167
|
+
"source_ref",
|
|
168
|
+
]:
|
|
169
|
+
if merged.get(field) is None and candidate.get(field) is not None:
|
|
170
|
+
merged[field] = candidate[field]
|
|
171
|
+
|
|
172
|
+
# The structured marker qualifies the source_ref; keep them in sync when
|
|
173
|
+
# the merge adopts the candidate's ref, else a structured rollout ref
|
|
174
|
+
# would read as low-confidence scraped text.
|
|
175
|
+
if merged.get("source_ref") is not None and merged["source_ref"] == candidate.get("source_ref"):
|
|
176
|
+
merged["structured"] = bool(candidate.get("structured"))
|
|
177
|
+
|
|
178
|
+
merged["updated_at"] = candidate.get("updated_at") or current.get("updated_at")
|
|
179
|
+
return merged
|
|
180
|
+
|
|
181
|
+
def _compute_available_pct(status):
|
|
182
|
+
if not status:
|
|
183
|
+
return None
|
|
184
|
+
values = [
|
|
185
|
+
_normalize_pct_value(status.get("remaining_5h_pct")),
|
|
186
|
+
_normalize_pct_value(status.get("remaining_week_pct")),
|
|
187
|
+
]
|
|
188
|
+
values = [value for value in values if value is not None]
|
|
189
|
+
if not values:
|
|
190
|
+
return None
|
|
191
|
+
return min(values)
|
|
192
|
+
|
|
193
|
+
def _is_low_confidence_status_source(status):
|
|
194
|
+
if not status:
|
|
195
|
+
return False
|
|
196
|
+
if status.get("structured"):
|
|
197
|
+
# Exact rate_limits API data: trustworthy even when it comes from a
|
|
198
|
+
# sessions/rollout path, which otherwise flags scraped-text noise.
|
|
199
|
+
return False
|
|
200
|
+
source_ref = str(status.get("source_ref") or "").replace(os.sep, "/")
|
|
201
|
+
return "/sessions/" in source_ref and "/rollout" in source_ref
|
|
202
|
+
|
|
203
|
+
def _read_expected_account_email(auth_home):
|
|
204
|
+
auth_path = os.path.join(auth_home, "auth.json")
|
|
205
|
+
try:
|
|
206
|
+
with open(auth_path, encoding="utf-8") as handle:
|
|
207
|
+
auth = json.load(handle)
|
|
208
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
tokens = auth.get("tokens") or {}
|
|
212
|
+
for token_name in ("id_token", "access_token"):
|
|
213
|
+
claims = _decode_jwt_claims(tokens.get(token_name))
|
|
214
|
+
email = claims.get("email")
|
|
215
|
+
if not email and token_name == "access_token":
|
|
216
|
+
profile = claims.get("https://api.openai.com/profile") or {}
|
|
217
|
+
email = profile.get("email")
|
|
218
|
+
if email:
|
|
219
|
+
return str(email).strip().lower()
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def _status_cache_hit(s, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS, cache_only=False):
|
|
223
|
+
return (
|
|
224
|
+
cache_only
|
|
225
|
+
or s.get("enabled", True) is False
|
|
226
|
+
or (
|
|
227
|
+
s.get("lastStatus")
|
|
228
|
+
and not force_refresh
|
|
229
|
+
and _is_status_cache_fresh(s, ttl_seconds=cache_ttl_seconds)
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def record_status(store, name, payload):
|
|
234
|
+
normalized = _normalize_status_payload(payload)
|
|
235
|
+
updated = store["update_session"](name, lambda s: {
|
|
236
|
+
**s,
|
|
237
|
+
"lastStatus": normalized,
|
|
238
|
+
"lastStatusAt": normalized["updated_at"],
|
|
239
|
+
})
|
|
240
|
+
if not updated:
|
|
241
|
+
raise CdxError(f"Unknown session: {name}")
|
|
242
|
+
return updated
|
|
243
|
+
|
|
244
|
+
def _fetch_codex_status(codex_status_fetcher, custom_codex_status_fetcher, default_status_timeout_seconds, session, timeout_seconds=None):
|
|
245
|
+
if custom_codex_status_fetcher:
|
|
246
|
+
return custom_codex_status_fetcher(session)
|
|
247
|
+
timeout = timeout_seconds or default_status_timeout_seconds
|
|
248
|
+
return codex_status_fetcher(session, timeout=timeout)
|
|
249
|
+
|
|
250
|
+
def _status_row_from_session(store, s):
|
|
251
|
+
status = s.get("lastStatus")
|
|
252
|
+
enabled = s.get("enabled", True) is not False
|
|
253
|
+
row_status = status if enabled else None
|
|
254
|
+
return {
|
|
255
|
+
"session_name": s["name"],
|
|
256
|
+
"label": s.get("label"),
|
|
257
|
+
"provider": s["provider"],
|
|
258
|
+
"enabled": enabled,
|
|
259
|
+
# Carried so the ranking can honor the user's `--priority` and the
|
|
260
|
+
# configured effort wherever it runs. Without these on the row, the
|
|
261
|
+
# recommendation path could not see either, which is why `cdx next`
|
|
262
|
+
# ignored `--priority` entirely.
|
|
263
|
+
"priority": _row_priority(s),
|
|
264
|
+
"reasoning_effort": _row_reasoning_effort(s),
|
|
265
|
+
"active": bool(_session_runtime(store, s["name"])) if enabled else False,
|
|
266
|
+
"status": "enabled" if enabled else "disabled",
|
|
267
|
+
"auth_status": (s.get("auth") or {}).get("status") or "unknown",
|
|
268
|
+
"auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
|
|
269
|
+
"remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
|
|
270
|
+
"remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
|
|
271
|
+
"credits": row_status.get("credits") if row_status else None,
|
|
272
|
+
"reset_credits_available": row_status.get("reset_credits_available") if row_status else None,
|
|
273
|
+
"reset_credits": row_status.get("reset_credits") if row_status else None,
|
|
274
|
+
"available_pct": _compute_available_pct(row_status),
|
|
275
|
+
"reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
|
|
276
|
+
"reset_week_at": row_status.get("reset_week_at") if row_status else None,
|
|
277
|
+
"reset_at": row_status.get("reset_at") if row_status else None,
|
|
278
|
+
"updated_at": _to_local_iso(s.get("lastStatusAt")),
|
|
279
|
+
"last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
def format_list_rows(store):
|
|
283
|
+
sessions = list_sessions(store)
|
|
284
|
+
providers = {s["provider"] for s in sessions}
|
|
285
|
+
has_multiple = len(providers) > 1
|
|
286
|
+
sessions = sorted(
|
|
287
|
+
sessions,
|
|
288
|
+
key=lambda s: (
|
|
289
|
+
1 if s.get("enabled", True) is False else 0,
|
|
290
|
+
s.get("name", ""),
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
return [{
|
|
294
|
+
"name": s["name"],
|
|
295
|
+
"label": s.get("label"),
|
|
296
|
+
"provider": s["provider"] if has_multiple else None,
|
|
297
|
+
"enabled": s.get("enabled", True) is not False,
|
|
298
|
+
"active": bool(_session_runtime(store, s["name"])) if s.get("enabled", True) is not False else False,
|
|
299
|
+
"enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
300
|
+
"status": s.get("lastStatus"),
|
|
301
|
+
"launch": s.get("launch") or {},
|
|
302
|
+
"updated_at": _to_local_iso(s.get("updatedAt")),
|
|
303
|
+
} for s in sessions]
|
|
304
|
+
|
|
305
|
+
def _resolve_session_status(store, base_dir, env, fetch_codex_status, session,
|
|
306
|
+
force_refresh=False,
|
|
307
|
+
cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
|
|
308
|
+
cache_only=False,
|
|
309
|
+
status_timeout_seconds=None,):
|
|
310
|
+
current_status = session.get("lastStatus")
|
|
311
|
+
if session.get("enabled", True) is False:
|
|
312
|
+
return current_status
|
|
313
|
+
if cache_only:
|
|
314
|
+
return current_status
|
|
315
|
+
if current_status and not force_refresh and _is_status_cache_fresh(session, ttl_seconds=cache_ttl_seconds):
|
|
316
|
+
return current_status
|
|
317
|
+
source_root = session.get("authHome") or _get_session_auth_home(base_dir,
|
|
318
|
+
session["name"], session["provider"]
|
|
319
|
+
)
|
|
320
|
+
if session["provider"] == PROVIDER_CODEX and fetch_codex_status:
|
|
321
|
+
live_status = fetch_codex_status(
|
|
322
|
+
{**session, "authHome": source_root},
|
|
323
|
+
timeout_seconds=status_timeout_seconds,
|
|
324
|
+
)
|
|
325
|
+
if live_status:
|
|
326
|
+
record_status(store, session["name"], live_status)
|
|
327
|
+
return live_status
|
|
328
|
+
|
|
329
|
+
expected_account_email = (
|
|
330
|
+
_read_expected_account_email(source_root)
|
|
331
|
+
if session["provider"] == PROVIDER_CODEX
|
|
332
|
+
else None
|
|
333
|
+
)
|
|
334
|
+
artifact = find_latest_status_artifact(
|
|
335
|
+
source_root,
|
|
336
|
+
session["provider"],
|
|
337
|
+
expected_account_email=expected_account_email,
|
|
338
|
+
)
|
|
339
|
+
if (
|
|
340
|
+
session["provider"] == PROVIDER_CODEX
|
|
341
|
+
and not artifact
|
|
342
|
+
and os.path.abspath(base_dir) == os.path.abspath(get_cdx_home(env))
|
|
343
|
+
):
|
|
344
|
+
global_root = _get_global_codex_home(env)
|
|
345
|
+
if global_root and os.path.abspath(global_root) != os.path.abspath(source_root):
|
|
346
|
+
artifact = find_latest_status_artifact(
|
|
347
|
+
global_root,
|
|
348
|
+
session["provider"],
|
|
349
|
+
expected_account_email=expected_account_email,
|
|
350
|
+
# The shared codex home can hold other accounts' rollouts;
|
|
351
|
+
# structured payloads there cannot be attributed to ours.
|
|
352
|
+
trust_unattributed_structured=False,
|
|
353
|
+
)
|
|
354
|
+
if not artifact:
|
|
355
|
+
if _is_low_confidence_status_source(current_status):
|
|
356
|
+
return None
|
|
357
|
+
return current_status
|
|
358
|
+
resolved = _normalize_status_payload({
|
|
359
|
+
"usage_pct": artifact.get("usage_pct"),
|
|
360
|
+
"remaining_5h_pct": artifact.get("remaining_5h_pct"),
|
|
361
|
+
"remaining_week_pct": artifact.get("remaining_week_pct"),
|
|
362
|
+
"credits": artifact.get("credits"),
|
|
363
|
+
"reset_5h_at": artifact.get("reset_5h_at"),
|
|
364
|
+
"reset_week_at": artifact.get("reset_week_at"),
|
|
365
|
+
"reset_at": artifact.get("reset_at"),
|
|
366
|
+
"updated_at": artifact.get("updated_at"),
|
|
367
|
+
"raw_status_text": artifact.get("raw_status_text"),
|
|
368
|
+
"source_ref": artifact.get("source_ref"),
|
|
369
|
+
"structured": artifact.get("structured"),
|
|
370
|
+
})
|
|
371
|
+
if _is_low_confidence_status_source(current_status) and not _is_low_confidence_status_source(resolved):
|
|
372
|
+
record_status(store, session["name"], resolved)
|
|
373
|
+
return resolved
|
|
374
|
+
if _is_status_newer(resolved, current_status):
|
|
375
|
+
record_status(store, session["name"], resolved)
|
|
376
|
+
return resolved
|
|
377
|
+
if _status_has_more_detail(resolved, current_status):
|
|
378
|
+
merged = _merge_status_payload(current_status, resolved)
|
|
379
|
+
record_status(store, session["name"], merged)
|
|
380
|
+
return merged
|
|
381
|
+
return current_status or resolved
|
|
382
|
+
|
|
383
|
+
def _resolve_row_session(store, base_dir, env, fetch_codex_status, s,
|
|
384
|
+
force_refresh=False,
|
|
385
|
+
cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
|
|
386
|
+
cache_only=False,
|
|
387
|
+
status_timeout_seconds=None,):
|
|
388
|
+
status = _resolve_session_status(store, base_dir, env, fetch_codex_status,
|
|
389
|
+
s,
|
|
390
|
+
force_refresh=force_refresh,
|
|
391
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
392
|
+
cache_only=cache_only,
|
|
393
|
+
status_timeout_seconds=status_timeout_seconds,
|
|
394
|
+
)
|
|
395
|
+
return {
|
|
396
|
+
**s,
|
|
397
|
+
"lastStatus": status,
|
|
398
|
+
"lastStatusAt": (status and status.get("updated_at")) or s.get("lastStatusAt"),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
def get_status_row(store, base_dir, env, fetch_codex_status, name,
|
|
402
|
+
progress_callback=None,
|
|
403
|
+
force_refresh=False,
|
|
404
|
+
cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
|
|
405
|
+
cache_only=False,
|
|
406
|
+
status_timeout_seconds=None,):
|
|
407
|
+
session = store["get_session"](name)
|
|
408
|
+
if not session:
|
|
409
|
+
raise CdxError(f"Unknown session: {name}")
|
|
410
|
+
cache_hit = _status_cache_hit(
|
|
411
|
+
session,
|
|
412
|
+
force_refresh=force_refresh,
|
|
413
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
414
|
+
cache_only=cache_only,
|
|
415
|
+
)
|
|
416
|
+
if progress_callback:
|
|
417
|
+
progress_callback({
|
|
418
|
+
"event": "status_started",
|
|
419
|
+
"session_count": 1,
|
|
420
|
+
"check_count": 0 if cache_hit else 1,
|
|
421
|
+
})
|
|
422
|
+
if not cache_hit:
|
|
423
|
+
progress_callback({
|
|
424
|
+
"event": "session_started",
|
|
425
|
+
"session_name": session["name"],
|
|
426
|
+
"provider": session["provider"],
|
|
427
|
+
})
|
|
428
|
+
resolved = _resolve_row_session(store, base_dir, env, fetch_codex_status,
|
|
429
|
+
session,
|
|
430
|
+
force_refresh=force_refresh,
|
|
431
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
432
|
+
cache_only=cache_only,
|
|
433
|
+
status_timeout_seconds=status_timeout_seconds,
|
|
434
|
+
)
|
|
435
|
+
if progress_callback:
|
|
436
|
+
progress_callback({
|
|
437
|
+
"event": "session_finished",
|
|
438
|
+
"session_name": session["name"],
|
|
439
|
+
"has_status": bool(resolved.get("lastStatus")),
|
|
440
|
+
"cache_hit": cache_hit,
|
|
441
|
+
})
|
|
442
|
+
progress_callback({
|
|
443
|
+
"event": "status_finished",
|
|
444
|
+
"row_count": 1,
|
|
445
|
+
})
|
|
446
|
+
return _status_row_from_session(store, resolved)
|
|
447
|
+
|
|
448
|
+
def get_status_rows(store, base_dir, env, fetch_codex_status, progress_callback=None,
|
|
449
|
+
force_refresh=False,
|
|
450
|
+
cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
|
|
451
|
+
cache_only=False,
|
|
452
|
+
status_timeout_seconds=None,):
|
|
453
|
+
sessions = list_sessions(store)
|
|
454
|
+
|
|
455
|
+
cache_hits = {
|
|
456
|
+
s["name"]: _status_cache_hit(
|
|
457
|
+
s,
|
|
458
|
+
force_refresh=force_refresh,
|
|
459
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
460
|
+
cache_only=cache_only,
|
|
461
|
+
)
|
|
462
|
+
for s in sessions
|
|
463
|
+
}
|
|
464
|
+
if progress_callback:
|
|
465
|
+
progress_callback({
|
|
466
|
+
"event": "status_started",
|
|
467
|
+
"session_count": len(sessions),
|
|
468
|
+
"check_count": sum(1 for cache_hit in cache_hits.values() if not cache_hit),
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
resolved_by_name = {}
|
|
472
|
+
if sessions:
|
|
473
|
+
max_workers = min(MAX_STATUS_WORKERS, len(sessions))
|
|
474
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
475
|
+
futures = {}
|
|
476
|
+
for s in sessions:
|
|
477
|
+
cache_hit = cache_hits[s["name"]]
|
|
478
|
+
if progress_callback and not cache_hit:
|
|
479
|
+
progress_callback({
|
|
480
|
+
"event": "session_started",
|
|
481
|
+
"session_name": s["name"],
|
|
482
|
+
"provider": s["provider"],
|
|
483
|
+
})
|
|
484
|
+
futures[executor.submit(
|
|
485
|
+
_resolve_row_session,
|
|
486
|
+
store,
|
|
487
|
+
base_dir,
|
|
488
|
+
env,
|
|
489
|
+
fetch_codex_status,
|
|
490
|
+
s,
|
|
491
|
+
force_refresh=force_refresh,
|
|
492
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
493
|
+
cache_only=cache_only,
|
|
494
|
+
status_timeout_seconds=status_timeout_seconds,
|
|
495
|
+
)] = s
|
|
496
|
+
for future in as_completed(futures):
|
|
497
|
+
s = futures[future]
|
|
498
|
+
try:
|
|
499
|
+
resolved = future.result()
|
|
500
|
+
except CdxError as error:
|
|
501
|
+
if not str(error).startswith("Unknown session:"):
|
|
502
|
+
raise
|
|
503
|
+
continue
|
|
504
|
+
resolved_by_name[s["name"]] = resolved
|
|
505
|
+
if progress_callback:
|
|
506
|
+
progress_callback({
|
|
507
|
+
"event": "session_finished",
|
|
508
|
+
"session_name": s["name"],
|
|
509
|
+
"has_status": bool(resolved.get("lastStatus")),
|
|
510
|
+
"cache_hit": cache_hits[s["name"]],
|
|
511
|
+
})
|
|
512
|
+
resolved = [resolved_by_name[s["name"]] for s in sessions if s["name"] in resolved_by_name]
|
|
513
|
+
|
|
514
|
+
def sort_key(s):
|
|
515
|
+
at = s.get("lastStatusAt") or ""
|
|
516
|
+
disabled_rank = 1 if s.get("enabled", True) is False else 0
|
|
517
|
+
return (disabled_rank, "" if at else "\xff", at, s["name"])
|
|
518
|
+
|
|
519
|
+
resolved.sort(key=sort_key)
|
|
520
|
+
enabled = [s for s in resolved if s.get("enabled", True) is not False]
|
|
521
|
+
disabled = [s for s in resolved if s.get("enabled", True) is False]
|
|
522
|
+
enabled.reverse()
|
|
523
|
+
disabled.sort(key=lambda s: s["name"])
|
|
524
|
+
resolved = enabled + disabled
|
|
525
|
+
|
|
526
|
+
rows = []
|
|
527
|
+
for s in resolved:
|
|
528
|
+
rows.append(_status_row_from_session(store, s))
|
|
529
|
+
if progress_callback:
|
|
530
|
+
progress_callback({
|
|
531
|
+
"event": "status_finished",
|
|
532
|
+
"row_count": len(rows),
|
|
533
|
+
})
|
|
534
|
+
return rows
|
package/src/status_view.py
CHANGED
|
@@ -9,6 +9,7 @@ from .cli_render import (
|
|
|
9
9
|
_style,
|
|
10
10
|
_style_pct,
|
|
11
11
|
)
|
|
12
|
+
from .session_ranking import rank_sessions
|
|
12
13
|
|
|
13
14
|
RESET_COUNTDOWN_SAFETY_SECONDS = 60
|
|
14
15
|
PRIORITY_EMPTY_AVAILABLE_THRESHOLD = 5
|
|
@@ -140,13 +141,17 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
140
141
|
_style(_format_relative_age(r.get("updated_at")), "2", use_color),
|
|
141
142
|
]
|
|
142
143
|
table_rows.append(base)
|
|
144
|
+
# "Recommended", not "Priority": the `--priority` setting is one input to
|
|
145
|
+
# this ranking among several, and labelling the whole recommendation with
|
|
146
|
+
# its name made users tune `--priority` and conclude it did nothing when
|
|
147
|
+
# availability or resets had decided instead.
|
|
143
148
|
priority_line = (
|
|
144
|
-
f"
|
|
149
|
+
f"Recommended: {_priority_instruction(priority[0], 'first')}"
|
|
145
150
|
+ (
|
|
146
151
|
f", {_priority_instruction(priority[1], 'next')}."
|
|
147
152
|
if len(priority) > 1 else "."
|
|
148
153
|
)
|
|
149
|
-
) if priority else "
|
|
154
|
+
) if priority else "Recommended: no usable session status yet."
|
|
150
155
|
current_line = _format_current_session_line(rows)
|
|
151
156
|
return "\n".join([
|
|
152
157
|
_pad_table([headers] + table_rows),
|
|
@@ -187,44 +192,20 @@ def _format_auth_status(row):
|
|
|
187
192
|
|
|
188
193
|
|
|
189
194
|
def recommend_priority_rows(rows):
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
"""Sessions to recommend, best first.
|
|
196
|
+
|
|
197
|
+
Delegates to the one ranking every selector shares, so `cdx next`, `cdx
|
|
198
|
+
status`, and `cdx ready` cannot disagree with `cdx select` and `cdx run
|
|
199
|
+
--provider` about which session is best.
|
|
200
|
+
"""
|
|
201
|
+
ordered, _decision = rank_sessions(rows, _now_timestamp(), _priority_reset_timestamp)
|
|
202
|
+
return ordered
|
|
196
203
|
|
|
197
204
|
|
|
198
205
|
def format_priority_instruction(row, position="first"):
|
|
199
206
|
return _priority_instruction(row, position)
|
|
200
207
|
|
|
201
208
|
|
|
202
|
-
def _recommend_priority_sessions(rows):
|
|
203
|
-
if not rows:
|
|
204
|
-
return []
|
|
205
|
-
|
|
206
|
-
def rank(row):
|
|
207
|
-
has_credits = row.get("credits") is not None
|
|
208
|
-
credit_rank = 0 if has_credits else 1
|
|
209
|
-
available = row.get("available_pct")
|
|
210
|
-
usable_now = available is not None and available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD
|
|
211
|
-
known_available = available is not None
|
|
212
|
-
reset_timestamp = _priority_reset_timestamp(row)
|
|
213
|
-
reset_is_future = reset_timestamp is not None and reset_timestamp >= _now_timestamp()
|
|
214
|
-
blocked_future = not usable_now and reset_is_future
|
|
215
|
-
reset_is_known = reset_timestamp is not None
|
|
216
|
-
reset_rank = -reset_timestamp if reset_is_known else float("-inf")
|
|
217
|
-
available_rank = available if available is not None else -1
|
|
218
|
-
name_rank = row.get("session_name") or ""
|
|
219
|
-
if usable_now:
|
|
220
|
-
return (3, credit_rank, 1 if known_available else 0, available_rank, reset_rank, name_rank)
|
|
221
|
-
if blocked_future:
|
|
222
|
-
return (2, 1 if reset_is_known else 0, reset_rank, credit_rank, available_rank, name_rank)
|
|
223
|
-
if reset_is_known:
|
|
224
|
-
return (1, reset_rank, credit_rank, 1 if known_available else 0, available_rank, name_rank)
|
|
225
|
-
return (0, credit_rank, 1 if known_available else 0, available_rank, name_rank)
|
|
226
|
-
|
|
227
|
-
return sorted(rows, key=rank, reverse=True)
|
|
228
209
|
|
|
229
210
|
|
|
230
211
|
def _format_blocking_quota(row):
|