cdx-manager 0.5.7 → 0.6.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/README.md +42 -2
- package/changelogs/CHANGELOGS_0_6_0.md +47 -0
- package/changelogs/CHANGELOGS_0_6_1.md +30 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +19 -4
- package/src/cli_commands.py +528 -23
- package/src/cli_render.py +21 -22
- package/src/provider_runtime.py +47 -4
- package/src/session_service.py +122 -0
- package/src/session_store.py +36 -0
- package/src/status_view.py +15 -3
- package/src/update_check.py +47 -8
package/src/cli_render.py
CHANGED
|
@@ -98,6 +98,19 @@ def _dim(text, use_color=False):
|
|
|
98
98
|
return _style(text, "2", use_color)
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def _format_launch_text(launch):
|
|
102
|
+
if not launch:
|
|
103
|
+
return "default"
|
|
104
|
+
parts = []
|
|
105
|
+
if launch.get("power"):
|
|
106
|
+
parts.append(launch["power"])
|
|
107
|
+
if launch.get("permission"):
|
|
108
|
+
parts.append(launch["permission"])
|
|
109
|
+
if launch.get("fast") is True:
|
|
110
|
+
parts.append("fast-on")
|
|
111
|
+
return "/".join(parts) or "default"
|
|
112
|
+
|
|
113
|
+
|
|
101
114
|
def format_error(error, env=None, stderr=None):
|
|
102
115
|
return _style(str(error), "31", _should_use_color(env or os.environ, stderr or sys.stderr))
|
|
103
116
|
|
|
@@ -116,40 +129,26 @@ def _format_sessions(service, use_color=False):
|
|
|
116
129
|
headers = [_style(header, "1", use_color) for header in headers]
|
|
117
130
|
table_rows = []
|
|
118
131
|
for r in rows:
|
|
119
|
-
|
|
132
|
+
name = f"{r['name']}*" if r.get("active") else r["name"]
|
|
133
|
+
parts = [name]
|
|
120
134
|
if has_provider:
|
|
121
135
|
parts.append(r.get("provider") or "n/a")
|
|
122
136
|
status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
123
137
|
parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
124
138
|
if has_launch:
|
|
125
139
|
launch = r.get("launch") or {}
|
|
126
|
-
|
|
127
|
-
launch_text = "/".join([
|
|
128
|
-
launch.get("power") or "-",
|
|
129
|
-
launch.get("permission") or "-",
|
|
130
|
-
"fast-on" if launch.get("fast") is True else "fast-off" if launch.get("fast") is False else "-",
|
|
131
|
-
])
|
|
132
|
-
else:
|
|
133
|
-
launch_text = "default"
|
|
134
|
-
parts.append(_dim(launch_text, use_color))
|
|
140
|
+
parts.append(_dim(_format_launch_text(launch), use_color))
|
|
135
141
|
parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
|
|
136
142
|
table_rows.append(parts)
|
|
137
143
|
lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
|
|
138
144
|
lines += [
|
|
139
145
|
_style("Next actions:", "1", use_color),
|
|
140
|
-
f" {_style('cdx
|
|
141
|
-
f" {_style('cdx
|
|
142
|
-
f" {_style('cdx login <name>', '36', use_color)}",
|
|
143
|
-
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
144
|
-
f" {_style('cdx config <name>', '36', use_color)}",
|
|
146
|
+
f" {_style('cdx status', '36', use_color)}",
|
|
147
|
+
f" {_style('cdx ready', '36', use_color)}",
|
|
145
148
|
f" {_style('cdx set <name> --power medium --permission default --fast off', '36', use_color)}",
|
|
146
|
-
f" {_style('cdx context show', '36', use_color)}",
|
|
147
|
-
f" {_style('cdx handoff <name>', '36', use_color)}",
|
|
148
149
|
f" {_style('cdx handoff <source> <target>', '36', use_color)}",
|
|
149
|
-
f" {_style('cdx
|
|
150
|
-
f" {_style('cdx
|
|
151
|
-
f" {_style('cdx
|
|
152
|
-
f" {_style('cdx rmv <name>', '36', use_color)}",
|
|
153
|
-
f" {_style('cdx status', '36', use_color)}",
|
|
150
|
+
f" {_style('cdx history', '36', use_color)}",
|
|
151
|
+
f" {_style('cdx help', '36', use_color)}",
|
|
152
|
+
f" {_style('cdx update', '36', use_color)}",
|
|
154
153
|
]
|
|
155
154
|
return "\n".join(lines)
|
package/src/provider_runtime.py
CHANGED
|
@@ -131,7 +131,7 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
131
131
|
args = args + [transcript_path]
|
|
132
132
|
args = args + [spec["command"]] + spec["args"]
|
|
133
133
|
else:
|
|
134
|
-
args =
|
|
134
|
+
args = _default_script_args(transcript_path, spec)
|
|
135
135
|
return {
|
|
136
136
|
"command": script_bin,
|
|
137
137
|
"args": args,
|
|
@@ -142,6 +142,13 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def _default_script_args(transcript_path, spec):
|
|
146
|
+
if sys.platform.startswith("linux"):
|
|
147
|
+
command = shlex.join([spec["command"]] + spec["args"])
|
|
148
|
+
return ["-q", "-F", "-c", command, transcript_path]
|
|
149
|
+
return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
|
|
150
|
+
|
|
151
|
+
|
|
145
152
|
def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
|
|
146
153
|
if initial_prompt is not None:
|
|
147
154
|
if not isinstance(initial_prompt, str):
|
|
@@ -277,7 +284,7 @@ def _signal_name(sig):
|
|
|
277
284
|
|
|
278
285
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
279
286
|
env_override=None, signal_emitter=None,
|
|
280
|
-
initial_prompt=None):
|
|
287
|
+
initial_prompt=None, lifecycle_callback=None):
|
|
281
288
|
spawn = spawn or subprocess.Popen
|
|
282
289
|
spec = (
|
|
283
290
|
_build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
|
|
@@ -293,11 +300,32 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
293
300
|
**{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
|
|
294
301
|
)
|
|
295
302
|
|
|
303
|
+
start_time = datetime.now(timezone.utc)
|
|
304
|
+
|
|
305
|
+
child_pid = None
|
|
306
|
+
|
|
307
|
+
def run_info(current_spec, returncode=None):
|
|
308
|
+
end_time = datetime.now(timezone.utc)
|
|
309
|
+
return {
|
|
310
|
+
"started_at": start_time.isoformat().replace("+00:00", "Z"),
|
|
311
|
+
"ended_at": end_time.isoformat().replace("+00:00", "Z"),
|
|
312
|
+
"duration_ms": int((end_time - start_time).total_seconds() * 1000),
|
|
313
|
+
"command": current_spec.get("command"),
|
|
314
|
+
"args": list(current_spec.get("args") or []),
|
|
315
|
+
"label": current_spec.get("label"),
|
|
316
|
+
"transcript_path": current_spec.get("transcript_path"),
|
|
317
|
+
"pid": child_pid,
|
|
318
|
+
"returncode": returncode,
|
|
319
|
+
}
|
|
320
|
+
|
|
296
321
|
try:
|
|
297
322
|
child = start_child(spec)
|
|
298
323
|
except FileNotFoundError as error:
|
|
299
324
|
spec = _fallback_launch_spec_or_raise(spec, error)
|
|
300
325
|
child = start_child(spec)
|
|
326
|
+
child_pid = getattr(child, "pid", None) or os.getpid()
|
|
327
|
+
if lifecycle_callback:
|
|
328
|
+
lifecycle_callback("started", run_info(spec))
|
|
301
329
|
|
|
302
330
|
forwarded_signal = None
|
|
303
331
|
handlers = []
|
|
@@ -336,6 +364,9 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
336
364
|
if forwarded_signal is None and child.returncode != 0 and _should_retry_without_transcript(spec):
|
|
337
365
|
spec = _fallback_launch_spec_or_raise(spec)
|
|
338
366
|
child = start_child(spec)
|
|
367
|
+
child_pid = getattr(child, "pid", None) or os.getpid()
|
|
368
|
+
if lifecycle_callback:
|
|
369
|
+
lifecycle_callback("started", run_info(spec))
|
|
339
370
|
child.wait()
|
|
340
371
|
finally:
|
|
341
372
|
if use_emitter:
|
|
@@ -352,14 +383,26 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
352
383
|
pass
|
|
353
384
|
|
|
354
385
|
if forwarded_signal is not None:
|
|
355
|
-
|
|
386
|
+
error = CdxError(
|
|
356
387
|
f"{spec['label']} interrupted by {_signal_name(forwarded_signal)} for session {session['name']}",
|
|
357
388
|
_signal_exit_code(forwarded_signal),
|
|
358
389
|
)
|
|
390
|
+
error.run_info = run_info(spec, returncode=error.exit_code)
|
|
391
|
+
if lifecycle_callback:
|
|
392
|
+
lifecycle_callback("finished", error.run_info)
|
|
393
|
+
raise error
|
|
359
394
|
if child.returncode != 0:
|
|
360
|
-
|
|
395
|
+
error = CdxError(
|
|
361
396
|
f"{spec['label']} exited with code {child.returncode} for session {session['name']}"
|
|
362
397
|
)
|
|
398
|
+
error.run_info = run_info(spec, returncode=child.returncode)
|
|
399
|
+
if lifecycle_callback:
|
|
400
|
+
lifecycle_callback("finished", error.run_info)
|
|
401
|
+
raise error
|
|
402
|
+
info = run_info(spec, returncode=child.returncode)
|
|
403
|
+
if lifecycle_callback:
|
|
404
|
+
lifecycle_callback("finished", info)
|
|
405
|
+
return info
|
|
363
406
|
|
|
364
407
|
|
|
365
408
|
def _fallback_launch_spec_or_raise(spec, original_error=None):
|
package/src/session_service.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import base64
|
|
5
5
|
import sys
|
|
6
6
|
import tempfile
|
|
7
|
+
import uuid
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
from urllib.parse import quote
|
|
9
10
|
|
|
@@ -28,11 +29,13 @@ RESERVED_SESSION_NAMES = {
|
|
|
28
29
|
"export",
|
|
29
30
|
"help",
|
|
30
31
|
"handoff",
|
|
32
|
+
"history",
|
|
31
33
|
"import",
|
|
32
34
|
"login",
|
|
33
35
|
"logout",
|
|
34
36
|
"mv",
|
|
35
37
|
"notify",
|
|
38
|
+
"ready",
|
|
36
39
|
"repair",
|
|
37
40
|
"ren",
|
|
38
41
|
"rename",
|
|
@@ -116,6 +119,24 @@ def _local_now_iso():
|
|
|
116
119
|
return datetime.now().astimezone().isoformat()
|
|
117
120
|
|
|
118
121
|
|
|
122
|
+
def _process_is_running(pid):
|
|
123
|
+
try:
|
|
124
|
+
pid = int(pid)
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
return False
|
|
127
|
+
if pid <= 0:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
os.kill(pid, 0)
|
|
131
|
+
except ProcessLookupError:
|
|
132
|
+
return False
|
|
133
|
+
except PermissionError:
|
|
134
|
+
return True
|
|
135
|
+
except OSError:
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
119
140
|
def _safe_relpath(path):
|
|
120
141
|
normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
|
|
121
142
|
if (
|
|
@@ -324,6 +345,32 @@ def create_session_service(options=None):
|
|
|
324
345
|
raise CdxError(f"Unsupported provider: {value}")
|
|
325
346
|
return value
|
|
326
347
|
|
|
348
|
+
def _runtime_is_active(runtime):
|
|
349
|
+
return (
|
|
350
|
+
isinstance(runtime, dict)
|
|
351
|
+
and runtime.get("status") == "running"
|
|
352
|
+
and _process_is_running(runtime.get("pid"))
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _session_runtime(name):
|
|
356
|
+
state = store["read_session_state"](name)
|
|
357
|
+
if not state:
|
|
358
|
+
return None
|
|
359
|
+
runtime = state.get("runtime")
|
|
360
|
+
if _runtime_is_active(runtime):
|
|
361
|
+
return runtime
|
|
362
|
+
if isinstance(runtime, dict) and runtime.get("status") == "running":
|
|
363
|
+
store["write_session_state"](name, {
|
|
364
|
+
**state,
|
|
365
|
+
"status": "ready",
|
|
366
|
+
"runtime": {
|
|
367
|
+
**runtime,
|
|
368
|
+
"status": "stale",
|
|
369
|
+
"endedAt": _local_now_iso(),
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
return None
|
|
373
|
+
|
|
327
374
|
def _validate_new_session_name(name):
|
|
328
375
|
if not name:
|
|
329
376
|
raise CdxError("Session name is required")
|
|
@@ -585,6 +632,56 @@ def create_session_service(options=None):
|
|
|
585
632
|
**s, "updatedAt": now, "lastLaunchedAt": now
|
|
586
633
|
})
|
|
587
634
|
|
|
635
|
+
def start_session_runtime(name, payload=None):
|
|
636
|
+
session = store["get_session"](name)
|
|
637
|
+
if not session:
|
|
638
|
+
raise CdxError(f"Unknown session: {name}")
|
|
639
|
+
state = store["read_session_state"](name)
|
|
640
|
+
if not state:
|
|
641
|
+
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
642
|
+
payload = dict(payload or {})
|
|
643
|
+
now = _local_now_iso()
|
|
644
|
+
runtime = {
|
|
645
|
+
"status": "running",
|
|
646
|
+
"runId": payload.get("runId") or uuid.uuid4().hex,
|
|
647
|
+
"startedAt": payload.get("startedAt") or now,
|
|
648
|
+
"pid": payload.get("pid") or os.getpid(),
|
|
649
|
+
"command": payload.get("command"),
|
|
650
|
+
"label": payload.get("label"),
|
|
651
|
+
"transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
|
|
652
|
+
}
|
|
653
|
+
store["write_session_state"](name, {
|
|
654
|
+
**state,
|
|
655
|
+
"status": "running",
|
|
656
|
+
"runtime": runtime,
|
|
657
|
+
})
|
|
658
|
+
return runtime
|
|
659
|
+
|
|
660
|
+
def finish_session_runtime(name, run_id=None, payload=None):
|
|
661
|
+
session = store["get_session"](name)
|
|
662
|
+
if not session:
|
|
663
|
+
raise CdxError(f"Unknown session: {name}")
|
|
664
|
+
state = store["read_session_state"](name)
|
|
665
|
+
if not state:
|
|
666
|
+
return None
|
|
667
|
+
runtime = state.get("runtime") or {}
|
|
668
|
+
if run_id and runtime.get("runId") != run_id:
|
|
669
|
+
return runtime
|
|
670
|
+
payload = dict(payload or {})
|
|
671
|
+
updated_runtime = {
|
|
672
|
+
**runtime,
|
|
673
|
+
"status": payload.get("status") or "stopped",
|
|
674
|
+
"endedAt": payload.get("endedAt") or _local_now_iso(),
|
|
675
|
+
}
|
|
676
|
+
if "returncode" in payload:
|
|
677
|
+
updated_runtime["returncode"] = payload["returncode"]
|
|
678
|
+
store["write_session_state"](name, {
|
|
679
|
+
**state,
|
|
680
|
+
"status": "ready",
|
|
681
|
+
"runtime": updated_runtime,
|
|
682
|
+
})
|
|
683
|
+
return updated_runtime
|
|
684
|
+
|
|
588
685
|
def ensure_session_state(name):
|
|
589
686
|
session = store["get_session"](name)
|
|
590
687
|
if not session:
|
|
@@ -669,6 +766,25 @@ def create_session_service(options=None):
|
|
|
669
766
|
raise CdxError(f"Unknown session: {name}")
|
|
670
767
|
return updated
|
|
671
768
|
|
|
769
|
+
def record_launch_history(name, payload):
|
|
770
|
+
session = store["get_session"](name)
|
|
771
|
+
if not session:
|
|
772
|
+
raise CdxError(f"Unknown session: {name}")
|
|
773
|
+
entry = {
|
|
774
|
+
"schema_version": 1,
|
|
775
|
+
"session_name": session["name"],
|
|
776
|
+
"provider": session["provider"],
|
|
777
|
+
"launch": session.get("launch") or {},
|
|
778
|
+
**payload,
|
|
779
|
+
}
|
|
780
|
+
store["append_launch_history"](entry)
|
|
781
|
+
return entry
|
|
782
|
+
|
|
783
|
+
def get_launch_history(name=None, limit=20):
|
|
784
|
+
if name and not store["get_session"](name):
|
|
785
|
+
raise CdxError(f"Unknown session: {name}")
|
|
786
|
+
return store["list_launch_history"](session_name=name, limit=limit)
|
|
787
|
+
|
|
672
788
|
def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
673
789
|
current_status = session.get("lastStatus")
|
|
674
790
|
if session.get("enabled", True) is False:
|
|
@@ -806,6 +922,7 @@ def create_session_service(options=None):
|
|
|
806
922
|
"session_name": s["name"],
|
|
807
923
|
"provider": s["provider"],
|
|
808
924
|
"enabled": enabled,
|
|
925
|
+
"active": bool(_session_runtime(s["name"])) if enabled else False,
|
|
809
926
|
"status": "enabled" if enabled else "disabled",
|
|
810
927
|
"remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
|
|
811
928
|
"remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
|
|
@@ -839,6 +956,7 @@ def create_session_service(options=None):
|
|
|
839
956
|
"name": s["name"],
|
|
840
957
|
"provider": s["provider"] if has_multiple else None,
|
|
841
958
|
"enabled": s.get("enabled", True) is not False,
|
|
959
|
+
"active": bool(_session_runtime(s["name"])) if s.get("enabled", True) is not False else False,
|
|
842
960
|
"enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
843
961
|
"status": s.get("lastStatus"),
|
|
844
962
|
"launch": s.get("launch") or {},
|
|
@@ -992,6 +1110,8 @@ def create_session_service(options=None):
|
|
|
992
1110
|
"copy_session": copy_session,
|
|
993
1111
|
"rename_session": rename_session,
|
|
994
1112
|
"launch_session": launch_session,
|
|
1113
|
+
"start_session_runtime": start_session_runtime,
|
|
1114
|
+
"finish_session_runtime": finish_session_runtime,
|
|
995
1115
|
"ensure_session_state": ensure_session_state,
|
|
996
1116
|
"list_sessions": list_sessions,
|
|
997
1117
|
"get_session": get_session,
|
|
@@ -999,6 +1119,8 @@ def create_session_service(options=None):
|
|
|
999
1119
|
"set_launch_settings": set_launch_settings,
|
|
1000
1120
|
"unset_launch_settings": unset_launch_settings,
|
|
1001
1121
|
"record_status": record_status,
|
|
1122
|
+
"record_launch_history": record_launch_history,
|
|
1123
|
+
"get_launch_history": get_launch_history,
|
|
1002
1124
|
"update_auth_state": update_auth_state,
|
|
1003
1125
|
"get_status_rows": get_status_rows,
|
|
1004
1126
|
"format_list_rows": format_list_rows,
|
package/src/session_store.py
CHANGED
|
@@ -105,6 +105,7 @@ def create_session_store(base_dir):
|
|
|
105
105
|
store_file = os.path.join(base_dir, "sessions.json")
|
|
106
106
|
lock_file = os.path.join(base_dir, ".sessions.lock")
|
|
107
107
|
state_dir = os.path.join(base_dir, "state")
|
|
108
|
+
launch_history_file = os.path.join(state_dir, "launch_history.jsonl")
|
|
108
109
|
|
|
109
110
|
def _state_file_path(name):
|
|
110
111
|
return os.path.join(state_dir, f"{_encode(name)}.json")
|
|
@@ -259,6 +260,39 @@ def create_session_store(base_dir):
|
|
|
259
260
|
with _file_lock(lock_file):
|
|
260
261
|
_write_session_state_unlocked(name, state)
|
|
261
262
|
|
|
263
|
+
def append_launch_history(entry):
|
|
264
|
+
with _file_lock(lock_file):
|
|
265
|
+
_ensure_dir(state_dir)
|
|
266
|
+
with open(launch_history_file, "a", encoding="utf-8") as handle:
|
|
267
|
+
handle.write(json.dumps(entry, separators=(",", ":")))
|
|
268
|
+
handle.write("\n")
|
|
269
|
+
handle.flush()
|
|
270
|
+
os.fsync(handle.fileno())
|
|
271
|
+
_fsync_directory(state_dir)
|
|
272
|
+
|
|
273
|
+
def list_launch_history(session_name=None, limit=20):
|
|
274
|
+
with _file_lock(lock_file):
|
|
275
|
+
try:
|
|
276
|
+
with open(launch_history_file, "r", encoding="utf-8") as handle:
|
|
277
|
+
lines = handle.readlines()
|
|
278
|
+
except FileNotFoundError:
|
|
279
|
+
return []
|
|
280
|
+
entries = []
|
|
281
|
+
for line in reversed(lines):
|
|
282
|
+
line = line.strip()
|
|
283
|
+
if not line:
|
|
284
|
+
continue
|
|
285
|
+
try:
|
|
286
|
+
entry = json.loads(line)
|
|
287
|
+
except json.JSONDecodeError as error:
|
|
288
|
+
raise CdxError(f"Corrupt JSONL file: {launch_history_file}") from error
|
|
289
|
+
if session_name and entry.get("session_name") != session_name:
|
|
290
|
+
continue
|
|
291
|
+
entries.append(entry)
|
|
292
|
+
if limit and len(entries) >= limit:
|
|
293
|
+
break
|
|
294
|
+
return entries
|
|
295
|
+
|
|
262
296
|
return {
|
|
263
297
|
"list_sessions": list_sessions,
|
|
264
298
|
"get_session": get_session,
|
|
@@ -269,4 +303,6 @@ def create_session_store(base_dir):
|
|
|
269
303
|
"replace_session": replace_session,
|
|
270
304
|
"read_session_state": read_session_state,
|
|
271
305
|
"write_session_state": write_session_state,
|
|
306
|
+
"append_launch_history": append_launch_history,
|
|
307
|
+
"list_launch_history": list_launch_history,
|
|
272
308
|
}
|
package/src/status_view.py
CHANGED
|
@@ -11,6 +11,7 @@ from .cli_render import (
|
|
|
11
11
|
|
|
12
12
|
RESET_COUNTDOWN_SAFETY_SECONDS = 60
|
|
13
13
|
PRIORITY_EMPTY_AVAILABLE_THRESHOLD = 5
|
|
14
|
+
BLOCKING_QUOTA_WARNING_THRESHOLD = 10
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _format_reset_time(value):
|
|
@@ -42,7 +43,11 @@ def _format_reset_time(value):
|
|
|
42
43
|
if remaining_minutes == 0:
|
|
43
44
|
return f"in {hours}h"
|
|
44
45
|
return f"in {hours}h {remaining_minutes}m"
|
|
45
|
-
|
|
46
|
+
days = int(delta_s // (24 * 60 * 60))
|
|
47
|
+
hours = int((delta_s % (24 * 60 * 60)) // (60 * 60))
|
|
48
|
+
if hours == 0:
|
|
49
|
+
return f"in {days}d"
|
|
50
|
+
return f"in {days}d {hours}h"
|
|
46
51
|
|
|
47
52
|
|
|
48
53
|
def _style_reset_time(value, use_color=False):
|
|
@@ -77,7 +82,7 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
77
82
|
priority = _recommend_priority_sessions(active_rows)
|
|
78
83
|
table_rows = []
|
|
79
84
|
for r in priority + disabled_rows:
|
|
80
|
-
base = [r
|
|
85
|
+
base = [_format_session_name(r)]
|
|
81
86
|
if has_provider:
|
|
82
87
|
base.append(r.get("provider") or "n/a")
|
|
83
88
|
status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
@@ -133,6 +138,10 @@ def _format_current_session_line(rows):
|
|
|
133
138
|
return f"Current: last launched {row['session_name']} ({label})."
|
|
134
139
|
|
|
135
140
|
|
|
141
|
+
def _format_session_name(row):
|
|
142
|
+
return f"{row['session_name']}*" if row.get("active") else row["session_name"]
|
|
143
|
+
|
|
144
|
+
|
|
136
145
|
def _recommend_priority_sessions(rows):
|
|
137
146
|
if not rows:
|
|
138
147
|
return []
|
|
@@ -166,6 +175,9 @@ def _format_blocking_quota(row):
|
|
|
166
175
|
remaining_week = row.get("remaining_week_pct")
|
|
167
176
|
if remaining_5h is None and remaining_week is None:
|
|
168
177
|
return "?"
|
|
178
|
+
known = [value for value in (remaining_5h, remaining_week) if value is not None]
|
|
179
|
+
if known and min(known) > BLOCKING_QUOTA_WARNING_THRESHOLD:
|
|
180
|
+
return "-"
|
|
169
181
|
if remaining_5h is None:
|
|
170
182
|
return "WEEK"
|
|
171
183
|
if remaining_week is None:
|
|
@@ -284,7 +296,7 @@ def _now_timestamp():
|
|
|
284
296
|
|
|
285
297
|
def _format_status_detail(row, use_color=False):
|
|
286
298
|
lines = [
|
|
287
|
-
f"{_style('Session:', '1', use_color)} {row
|
|
299
|
+
f"{_style('Session:', '1', use_color)} {_format_session_name(row)}",
|
|
288
300
|
f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
|
|
289
301
|
f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
|
|
290
302
|
f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
|
package/src/update_check.py
CHANGED
|
@@ -9,6 +9,10 @@ UPDATE_CHECK_TTL_SECONDS = 12 * 60 * 60
|
|
|
9
9
|
LATEST_RELEASE_URL = "https://api.github.com/repos/AlexAgo83/cdx-manager/releases/latest"
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class LatestReleaseCheckError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
12
16
|
def _parse_version(value):
|
|
13
17
|
raw = str(value or "").strip().lstrip("v")
|
|
14
18
|
parts = raw.split(".")
|
|
@@ -51,13 +55,22 @@ def _write_cache(path, payload):
|
|
|
51
55
|
handle.write("\n")
|
|
52
56
|
|
|
53
57
|
|
|
54
|
-
def
|
|
58
|
+
def _github_token(env=None):
|
|
59
|
+
env = env or os.environ
|
|
60
|
+
return env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fetch_latest_release(env=None):
|
|
64
|
+
headers = {
|
|
65
|
+
"Accept": "application/vnd.github+json",
|
|
66
|
+
"User-Agent": "cdx-manager-update-check",
|
|
67
|
+
}
|
|
68
|
+
token = _github_token(env)
|
|
69
|
+
if token:
|
|
70
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
55
71
|
request = urllib.request.Request(
|
|
56
72
|
LATEST_RELEASE_URL,
|
|
57
|
-
headers=
|
|
58
|
-
"Accept": "application/vnd.github+json",
|
|
59
|
-
"User-Agent": "cdx-manager-update-check",
|
|
60
|
-
},
|
|
73
|
+
headers=headers,
|
|
61
74
|
)
|
|
62
75
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
63
76
|
payload = json.loads(response.read().decode("utf-8"))
|
|
@@ -67,13 +80,39 @@ def _fetch_latest_release():
|
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
|
|
70
|
-
def
|
|
83
|
+
def _format_fetch_error(error):
|
|
84
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
85
|
+
if error.code == 403:
|
|
86
|
+
return (
|
|
87
|
+
"GitHub API rate limit reached while checking the latest cdx-manager release. "
|
|
88
|
+
"Try again later, set GH_TOKEN or GITHUB_TOKEN, or run cdx update --version <version>."
|
|
89
|
+
)
|
|
90
|
+
if error.code == 404:
|
|
91
|
+
return "Unable to find the latest cdx-manager release on GitHub."
|
|
92
|
+
return f"GitHub returned HTTP {error.code} while checking the latest cdx-manager release."
|
|
93
|
+
if isinstance(error, urllib.error.URLError):
|
|
94
|
+
reason = getattr(error, "reason", None)
|
|
95
|
+
suffix = f" ({reason})" if reason else ""
|
|
96
|
+
return f"Unable to reach GitHub while checking the latest cdx-manager release{suffix}."
|
|
97
|
+
if isinstance(error, TimeoutError):
|
|
98
|
+
return "Timed out while checking the latest cdx-manager release."
|
|
99
|
+
return "Unable to check for the latest cdx-manager release."
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fetch_latest_release(env=None):
|
|
71
103
|
try:
|
|
72
|
-
return _fetch_latest_release()
|
|
104
|
+
return _fetch_latest_release(env=env)
|
|
73
105
|
except (urllib.error.URLError, TimeoutError, ValueError, OSError):
|
|
74
106
|
return None
|
|
75
107
|
|
|
76
108
|
|
|
109
|
+
def fetch_latest_release_or_raise(env=None):
|
|
110
|
+
try:
|
|
111
|
+
return _fetch_latest_release(env=env)
|
|
112
|
+
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as error:
|
|
113
|
+
raise LatestReleaseCheckError(_format_fetch_error(error)) from error
|
|
114
|
+
|
|
115
|
+
|
|
77
116
|
def check_for_update(base_dir, current_version, env=None, now_fn=None):
|
|
78
117
|
env = env or os.environ
|
|
79
118
|
now_fn = now_fn or (lambda: datetime.now(timezone.utc).timestamp())
|
|
@@ -94,7 +133,7 @@ def check_for_update(base_dir, current_version, env=None, now_fn=None):
|
|
|
94
133
|
}
|
|
95
134
|
return None
|
|
96
135
|
|
|
97
|
-
latest = fetch_latest_release()
|
|
136
|
+
latest = fetch_latest_release(env=env)
|
|
98
137
|
if not latest:
|
|
99
138
|
return None
|
|
100
139
|
|