aisc-cli 0.1.0__py3-none-any.whl

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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,286 @@
1
+ """``aisc update`` (0.1.0 A4, D-7): self-update for the frozen CLI forms.
2
+
3
+ Forms and behavior:
4
+
5
+ - **frozen (Workbench sidecar / portable install)**: download the target
6
+ release archive (shared, verified path with ``bundle fetch``), verify the
7
+ bundle + manifest gate, then atomically replace the executable and the
8
+ adjacent ``aisc-bundle/``. The Windows rename dance: a RUNNING exe cannot
9
+ be overwritten but CAN be renamed — old becomes ``<name>.old-<ts>`` and
10
+ the new bytes land at the original path. Workbench's pooled local serve
11
+ sessions key on exe mtime, so the next command respawns from the new
12
+ binary automatically (serve.rs:610-647) — no Workbench restart needed.
13
+ ``--rebuild`` chains the installer-style ``maintenance docker-rebuild``
14
+ through the NEW executable (also a boot smoke test).
15
+ - **pip/pipx**: guidance only (``pipx upgrade aisc-cli``) — the package
16
+ manager owns the files (D-18: remote machines are out of scope).
17
+ - **source checkout**: refused — git owns the files.
18
+
19
+ Downgrade guard: latest-final resolution never moves backwards; an
20
+ explicit ``--version`` is honored as user intent.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import re
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import time
32
+ from pathlib import Path
33
+ from typing import Any, Dict, Optional, Tuple
34
+
35
+ import aisc
36
+ from aisc.application.bundle_fetch import (
37
+ BundleFetchError,
38
+ BundleFetcher,
39
+ _parse_archive_name,
40
+ bundle_compatible,
41
+ safe_extract_archive,
42
+ verify_staged_bundle,
43
+ )
44
+
45
+ UPDATE_ERROR_USAGE = "aisc.update/usage"
46
+ UPDATE_ERROR_STATE = "aisc.update/state"
47
+
48
+
49
+ class UpdateError(Exception):
50
+ def __init__(self, code: str, message: str) -> None:
51
+ super().__init__(message)
52
+ self.code = code
53
+ self.message = message
54
+
55
+
56
+ def _version_key(v: str) -> Tuple[int, ...]:
57
+ return tuple(int(p) for p in re.findall(r"\d+", v)[:3])
58
+
59
+
60
+ def detect_install_form(
61
+ *,
62
+ frozen: Optional[bool] = None,
63
+ executable: Optional[str] = None,
64
+ package_file: Optional[str] = None,
65
+ ) -> Dict[str, Any]:
66
+ """Pure-ish form detection (injectable for tests)."""
67
+ is_frozen = getattr(sys, "frozen", False) if frozen is None else frozen
68
+ if not is_frozen:
69
+ pkg = Path(package_file or aisc.__file__).resolve()
70
+ s = str(pkg)
71
+ if "site-packages" in s:
72
+ return {"form": "pip", "package": str(pkg)}
73
+ return {"form": "source", "package": str(pkg)}
74
+ exe = Path(executable or sys.executable).resolve()
75
+ adjacent = exe.parent / "aisc-bundle"
76
+ return {
77
+ "form": "frozen",
78
+ "exe": str(exe),
79
+ "adjacent_bundle": str(adjacent) if adjacent.is_dir() else None,
80
+ }
81
+
82
+
83
+ def sweep_old_files(exe_dir: Path) -> list:
84
+ """Remove pre-existing ``.old-<ts>`` leftovers from earlier updates.
85
+
86
+ Called BEFORE the replace dance: by then nothing references the old
87
+ .old files (they were the previous-previous binary)."""
88
+ removed = []
89
+ if not exe_dir.is_dir():
90
+ return removed
91
+ for p in exe_dir.iterdir():
92
+ if p.is_file() and ".old-" in p.name:
93
+ try:
94
+ p.unlink()
95
+ removed.append(p.name)
96
+ except OSError:
97
+ pass
98
+ for p in exe_dir.glob("aisc-bundle.old-*"):
99
+ if p.is_dir():
100
+ shutil.rmtree(p, ignore_errors=True)
101
+ removed.append(p.name)
102
+ return removed
103
+
104
+
105
+ def _replace_exe(exe: Path, new_bytes: bytes) -> Optional[Path]:
106
+ """Atomically swap the executable. Windows: rename dance (a running exe
107
+ can be renamed, not overwritten). POSIX: plain os.replace (inode swap,
108
+ running processes keep the old image). Returns the preserved old path
109
+ on Windows, None elsewhere."""
110
+ if os.name == "nt":
111
+ old = exe.with_name(f"{exe.stem}.old-{int(time.time())}{exe.suffix}")
112
+ os.rename(exe, old)
113
+ exe.write_bytes(new_bytes)
114
+ return old
115
+ tmp = exe.with_name(f"{exe.name}.new-{int(time.time())}")
116
+ tmp.write_bytes(new_bytes)
117
+ os.replace(tmp, exe)
118
+ return None
119
+
120
+
121
+ def _swap_adjacent_bundle(adjacent: Path, new_bundle: Path) -> Optional[Path]:
122
+ old = adjacent.with_name(f"{adjacent.name}.old-{int(time.time())}")
123
+ os.rename(adjacent, old)
124
+ shutil.move(str(new_bundle), str(adjacent))
125
+ return old
126
+
127
+
128
+ def build_check_report(
129
+ *,
130
+ cli_version: str,
131
+ form: Dict[str, Any],
132
+ fetcher: BundleFetcher,
133
+ requested_version: Optional[str] = None,
134
+ ) -> Dict[str, Any]:
135
+ """--check / dry-run plan. Never touches disk. Network is only touched
136
+ for the frozen form (pip/source answers are local facts)."""
137
+ report: Dict[str, Any] = {
138
+ "form": form["form"],
139
+ "cli_version": cli_version,
140
+ "latest_final": None,
141
+ "requested_version": requested_version,
142
+ }
143
+ if form["form"] == "pip":
144
+ report["update_available"] = None
145
+ report["action"] = "pipx upgrade aisc-cli (package manager owns the files)"
146
+ return report
147
+ if form["form"] == "source":
148
+ report["update_available"] = None
149
+ report["action"] = "refused: git checkout — update via git pull"
150
+ return report
151
+
152
+ latest = fetcher.latest_final_version()
153
+ report["latest_final"] = latest
154
+ target = requested_version or latest
155
+ if target is None:
156
+ report["update_available"] = False
157
+ report["action"] = "no final release published yet; nothing to update to"
158
+ else:
159
+ newer = _version_key(target) > _version_key(cli_version)
160
+ report["target_version"] = target
161
+ report["update_available"] = newer or bool(requested_version)
162
+ report["action"] = (
163
+ f"download AISC-{target} release archive, verify, replace exe"
164
+ + (f" + adjacent bundle at {form['adjacent_bundle']}"
165
+ if form.get("adjacent_bundle") else " (bundle store via aisc bundle fetch)")
166
+ )
167
+ return report
168
+
169
+
170
+ def perform_update(
171
+ *,
172
+ cli_version: str,
173
+ form: Dict[str, Any],
174
+ fetcher: BundleFetcher,
175
+ version: Optional[str] = None,
176
+ from_file: Optional[Path] = None,
177
+ sha256: Optional[str] = None,
178
+ rebuild: bool = False,
179
+ workdir: Optional[Path] = None,
180
+ ) -> Dict[str, Any]:
181
+ """Execute the update for the FROZEN form. Returns the report dict.
182
+
183
+ ``workdir`` overrides the exe directory (tests). Raises UpdateError /
184
+ BundleFetchError fail-closed; nothing is replaced unless every check
185
+ passed."""
186
+ import hashlib
187
+
188
+ if form["form"] == "pip":
189
+ raise UpdateError(
190
+ UPDATE_ERROR_USAGE,
191
+ "pip/pipx install: the package manager owns the files — run "
192
+ "`pipx upgrade aisc-cli` (or pip install -U aisc-cli in a venv)")
193
+ if form["form"] == "source":
194
+ raise UpdateError(
195
+ UPDATE_ERROR_USAGE,
196
+ "source checkout: git owns the files — update via git pull, "
197
+ "then rebuild the sidecar if needed")
198
+
199
+ target = version or fetcher.latest_final_version()
200
+ if target is None:
201
+ raise UpdateError(
202
+ UPDATE_ERROR_STATE,
203
+ "no final release published yet — nothing to update to "
204
+ "(explicit --version <ver> to force)")
205
+ if not version and _version_key(target) <= _version_key(cli_version):
206
+ return {"status": "up-to-date", "cli_version": cli_version, "latest_final": target}
207
+
208
+ exe = Path(form["exe"]) if not workdir else workdir / Path(form["exe"]).name
209
+ exe_dir = exe.parent
210
+
211
+ with tempfile.TemporaryDirectory(prefix="aisc-update-") as td:
212
+ tdp = Path(td)
213
+ if from_file is not None:
214
+ archive = Path(from_file).resolve()
215
+ if not archive.is_file():
216
+ raise UpdateError(UPDATE_ERROR_USAGE, f"--from-file not found: {archive}")
217
+ expected = (sha256 or "").strip().lower()
218
+ if not expected:
219
+ raise UpdateError(
220
+ UPDATE_ERROR_USAGE,
221
+ "--from-file requires --sha256 (same strength as the online path)")
222
+ actual = hashlib.sha256(archive.read_bytes()).hexdigest()
223
+ if actual != expected:
224
+ raise UpdateError(
225
+ UPDATE_ERROR_USAGE,
226
+ f"--from-file digest mismatch: expected {expected[:16]}..., "
227
+ f"got {actual[:16]}...")
228
+ else:
229
+ archive = fetcher.download_archive(target, tdp / "dl")
230
+
231
+ parsed = _parse_archive_name(archive.name)
232
+ archive_version = parsed[0] if parsed else None
233
+ if archive_version and archive_version != target and not version:
234
+ raise UpdateError(
235
+ UPDATE_ERROR_STATE,
236
+ f"archive is version {archive_version}, latest-final said {target}")
237
+
238
+ extracted = tdp / "x"
239
+ errs = safe_extract_archive(archive, extracted)
240
+ if errs:
241
+ raise UpdateError(UPDATE_ERROR_STATE, f"archive failed validation: {errs[0]}")
242
+ tops = [c for c in extracted.iterdir() if c.is_dir()]
243
+ if len(tops) != 1:
244
+ raise UpdateError(UPDATE_ERROR_STATE, "archive must have exactly one top-level dir")
245
+ top = tops[0]
246
+ exe_name = "aisc.exe" if os.name == "nt" else "aisc"
247
+ new_exe = top / exe_name
248
+ new_bundle = top / "aisc-bundle"
249
+ if not new_exe.is_file() or not new_bundle.is_dir():
250
+ raise UpdateError(UPDATE_ERROR_STATE, "archive lacks aisc executable or aisc-bundle/")
251
+
252
+ errs = verify_staged_bundle(new_bundle)
253
+ if errs:
254
+ raise UpdateError(UPDATE_ERROR_STATE, f"bundle verification failed: {errs[0]}")
255
+ ok, reason = bundle_compatible(new_bundle, archive_version or target)
256
+ if not ok:
257
+ raise UpdateError(UPDATE_ERROR_STATE, f"manifest gate: {reason}")
258
+
259
+ sweep_old_files(exe_dir)
260
+ old_exe = _replace_exe(exe, new_exe.read_bytes())
261
+ old_bundle = None
262
+ adjacent = Path(form["adjacent_bundle"]) if form.get("adjacent_bundle") else None
263
+ if adjacent is not None and not workdir:
264
+ old_bundle = _swap_adjacent_bundle(adjacent, new_bundle)
265
+
266
+ report = {
267
+ "status": "updated",
268
+ "from_version": cli_version,
269
+ "to_version": archive_version or target,
270
+ "exe": str(exe),
271
+ }
272
+ if old_exe is not None:
273
+ report["preserved_old_exe"] = str(old_exe)
274
+ if old_bundle is not None:
275
+ report["preserved_old_bundle"] = str(old_bundle)
276
+
277
+ if rebuild:
278
+ rebuild_root = str(adjacent) if adjacent is not None and not workdir else str(new_bundle)
279
+ cmd = [str(exe), "maintenance", "docker-rebuild", "--root", rebuild_root,
280
+ "--format", "json"]
281
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=2700)
282
+ report["rebuild_exit"] = proc.returncode
283
+ if proc.returncode != 0:
284
+ report["status"] = "updated-rebuild-failed"
285
+ report["rebuild_tail"] = (proc.stdout or proc.stderr)[-2000:]
286
+ return report
@@ -0,0 +1,305 @@
1
+ """Provider token usage aggregation — application layer (IDEA-2 2c).
2
+
3
+ Bridges the Workbench to the in-container cc-switch usage data (proxy
4
+ request logs + session-log imports, both landing in ``proxy_request_logs``
5
+ per 2a probe). Per workspace: running container → live ``aisc-cc-provider
6
+ usage`` exec (the host never opens the WAL database directly — Windows
7
+ bind-mount lock semantics are unreliable); stopped container → cached
8
+ snapshot under ``<data-root>/cache/usage/``; nothing → ``source: none``.
9
+ The subscription section reuses ``network_subscription.show_subscription``
10
+ host-side. Time windows are computed host-side (local timezone) and passed
11
+ as an epoch cutoff, so "today" means the user's today.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from datetime import datetime, time as dt_time
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from aisc.domain.models import CliError
23
+
24
+ PROTOCOL = "aisc.cc-switch-provider/v1"
25
+ _ADAPTER_PATH = "/usr/local/bin/aisc-cc-provider"
26
+ USAGE_TIMEOUT_S = 60.0
27
+ INSPECT_TIMEOUT_S = 10.0
28
+ RANGE_CHOICES = ("today", "7d", "30d")
29
+ CACHE_SCHEMA = "aisc.usage-cache/v1"
30
+
31
+ # The scope wrapper mirrors aisc.application.cc_switch_provider (kept in sync
32
+ # deliberately — importing that module would couple two unrelated data planes;
33
+ # the wrapper is the stable contract with the container's PID-1 environ).
34
+ _SCOPE_WRAPPER = "\n".join([
35
+ 'if [ ! -r "$1" ]; then',
36
+ ' echo \'Error: Cannot read scope environment from PID 1\' >&2',
37
+ ' exit 101',
38
+ 'fi',
39
+ 'unset CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME',
40
+ "while IFS= read -r -d '' entry; do",
41
+ ' case "$entry" in',
42
+ ' CLAUDE_CONFIG_DIR=*) CLAUDE_CONFIG_DIR=${entry#*=} ;;',
43
+ ' CC_SWITCH_CONFIG_DIR=*) CC_SWITCH_CONFIG_DIR=${entry#*=} ;;',
44
+ ' CODEX_CONFIG_DIR=*) CODEX_CONFIG_DIR=${entry#*=} ;;',
45
+ ' CODEX_HOME=*) CODEX_HOME=${entry#*=} ;;',
46
+ ' esac',
47
+ 'done < "$1"',
48
+ 'shift',
49
+ 'shift',
50
+ 'if [ -z "${CLAUDE_CONFIG_DIR:-}" ] || [ -z "${CC_SWITCH_CONFIG_DIR:-}" ]; then',
51
+ " echo 'Error: Cannot read scope environment from PID 1' >&2",
52
+ ' exit 101',
53
+ 'fi',
54
+ 'export CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME',
55
+ 'exec "$@"',
56
+ '',
57
+ ])
58
+
59
+
60
+ def _validate_range(range_key: str) -> str:
61
+ if range_key not in RANGE_CHOICES:
62
+ raise CliError(
63
+ message=f"invalid range (must be {'|'.join(RANGE_CHOICES)}): {range_key}",
64
+ exit_code=2,
65
+ error_code="AISC_ERR_USAGE",
66
+ )
67
+ return range_key
68
+
69
+
70
+ def since_epoch_for(range_key: str, *, now: Optional[datetime] = None) -> float:
71
+ """Local-timezone cutoff: today = local midnight; Nd = now - N*24h."""
72
+ _validate_range(range_key)
73
+ now = now or datetime.now()
74
+ if range_key == "today":
75
+ midnight = datetime.combine(now.date(), dt_time.min)
76
+ return midnight.timestamp()
77
+ days = int(range_key.rstrip("d"))
78
+ return now.timestamp() - days * 86400.0
79
+
80
+
81
+ def _container_running(executor: Any, container: str) -> bool:
82
+ result = executor.run_captured(
83
+ ["inspect", "--format", "{{.State.Running}}", container],
84
+ timeout=INSPECT_TIMEOUT_S,
85
+ )
86
+ return result.exit_code == 0 and (result.stdout or "").strip() == "true"
87
+
88
+
89
+ def _exec_usage_adapter(executor: Any, container: str, since: float) -> Optional[Dict[str, Any]]:
90
+ """Run the in-container usage op; ``None`` when the call yields no data
91
+ (container gone, adapter missing, envelope unusable)."""
92
+ argv = [
93
+ "exec", "-i", container,
94
+ "bash", "-c",
95
+ _SCOPE_WRAPPER,
96
+ "aisc-scope", "/proc/1/environ", "--",
97
+ _ADAPTER_PATH, "usage", "--since", f"{since:.0f}",
98
+ ]
99
+ result = executor.run_captured(argv, timeout=USAGE_TIMEOUT_S)
100
+ stdout = (result.stdout or "").strip()
101
+ if result.exit_code != 0 and not stdout:
102
+ return None
103
+ try:
104
+ envelope = json.loads(stdout.splitlines()[-1])
105
+ except (json.JSONDecodeError, IndexError):
106
+ return None
107
+ if not isinstance(envelope, dict) or envelope.get("schema") != PROTOCOL \
108
+ or not envelope.get("ok"):
109
+ return None
110
+ usage = envelope.get("usage")
111
+ return usage if isinstance(usage, dict) else None
112
+
113
+
114
+ def _cache_path(ws_dir_name: str) -> Path:
115
+ from aisc.application.data_root import shared_root
116
+
117
+ return shared_root() / "cache" / "usage" / f"{ws_dir_name}.json"
118
+
119
+
120
+ def _write_cache(ws_dir_name: str, range_key: str, usage: Dict[str, Any]) -> None:
121
+ path = _cache_path(ws_dir_name)
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ payload = {
124
+ "schema": CACHE_SCHEMA,
125
+ "range": range_key,
126
+ "fetched_at": datetime.now().astimezone().isoformat(timespec="seconds"),
127
+ "usage": usage,
128
+ }
129
+ tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
130
+ tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
131
+ os.replace(tmp, path)
132
+
133
+
134
+ def _read_cache(ws_dir_name: str, range_key: str) -> Optional[Dict[str, Any]]:
135
+ """Reuse a cached snapshot only when it answers the same question:
136
+ same range key, and for ``today`` fetched on the same local date."""
137
+ try:
138
+ payload = json.loads(_cache_path(ws_dir_name).read_text(encoding="utf-8"))
139
+ except (OSError, json.JSONDecodeError):
140
+ return None
141
+ if not isinstance(payload, dict) or payload.get("schema") != CACHE_SCHEMA \
142
+ or payload.get("range") != range_key:
143
+ return None
144
+ if range_key == "today":
145
+ fetched = str(payload.get("fetched_at") or "")[:10]
146
+ if fetched != datetime.now().astimezone().isoformat()[:10]:
147
+ return None
148
+ usage = payload.get("usage")
149
+ return usage if isinstance(usage, dict) else None
150
+
151
+
152
+ def _normalize_providers(usage: Dict[str, Any]) -> List[Dict[str, Any]]:
153
+ rows = usage.get("providers")
154
+ if not isinstance(rows, list):
155
+ return []
156
+ out: List[Dict[str, Any]] = []
157
+ for row in rows:
158
+ if not isinstance(row, dict):
159
+ continue
160
+ out.append({
161
+ "app": str(row.get("app") or ""),
162
+ "provider_id": str(row.get("provider_id") or ""),
163
+ "provider_name": str(row.get("provider_name") or row.get("provider_id") or ""),
164
+ "requests": int(row.get("requests") or 0),
165
+ "success": int(row.get("success") or 0),
166
+ "failed": int(row.get("failed") or 0),
167
+ "tokens_total": int(row.get("tokens_total") or 0),
168
+ "cost_estimate": float(row.get("cost_estimate") or 0.0),
169
+ "currency": str(row.get("currency") or "USD"),
170
+ })
171
+ return out
172
+
173
+
174
+ def _normalize_models(usage: Dict[str, Any]) -> List[Dict[str, Any]]:
175
+ rows = usage.get("models")
176
+ if not isinstance(rows, list):
177
+ return []
178
+ out: List[Dict[str, Any]] = []
179
+ for row in rows:
180
+ if not isinstance(row, dict):
181
+ continue
182
+ out.append({
183
+ "app": str(row.get("app") or ""),
184
+ "model": str(row.get("model") or ""),
185
+ "requests": int(row.get("requests") or 0),
186
+ "tokens_in": int(row.get("tokens_in") or 0),
187
+ "tokens_out": int(row.get("tokens_out") or 0),
188
+ "cost_estimate": float(row.get("cost_estimate") or 0.0),
189
+ })
190
+ return out
191
+
192
+
193
+ def _registry_entries(ws_dir: Path) -> Dict[str, Dict[str, Any]]:
194
+ from aisc.adapters.container_registry import list_containers_readonly
195
+
196
+ try:
197
+ entries = list_containers_readonly(ws_dir / "runtime")
198
+ except Exception:
199
+ return {}
200
+ return entries if isinstance(entries, dict) else {}
201
+
202
+
203
+ def usage_overview(
204
+ range_key: str = "7d",
205
+ workspace: Optional[str] = None,
206
+ executor: Any = None,
207
+ ) -> Dict[str, Any]:
208
+ """Aggregate usage across all data-root workspaces (IDEA-2 D2)."""
209
+ from aisc.adapters.docker_ import RealDockerExecutor
210
+ from aisc.application.data_root import shared_root
211
+ from aisc.application.network_subscription import show_subscription
212
+
213
+ _validate_range(range_key)
214
+ executor = executor or RealDockerExecutor()
215
+ since = since_epoch_for(range_key)
216
+
217
+ subscription = show_subscription()
218
+ want_workspace = str(Path(workspace).resolve()) if workspace else None
219
+
220
+ data_root = shared_root()
221
+ ws_root = data_root / "workspaces"
222
+ workspaces: List[Dict[str, Any]] = []
223
+ totals: Dict[tuple, Dict[str, Any]] = {}
224
+
225
+ for ws_dir in sorted(ws_root.glob("sha256-v1-*")) if ws_root.is_dir() else []:
226
+ entries = _registry_entries(ws_dir)
227
+ if not entries:
228
+ continue
229
+ ws_path = ""
230
+ for meta in entries.values():
231
+ if isinstance(meta, dict) and meta.get("workspace"):
232
+ ws_path = str(meta.get("workspace"))
233
+ break
234
+ if want_workspace and str(Path(ws_path).resolve() if ws_path else "") != want_workspace:
235
+ continue
236
+
237
+ running_container: Optional[str] = None
238
+ for name in entries:
239
+ try:
240
+ if _container_running(executor, name):
241
+ running_container = name
242
+ break
243
+ except Exception:
244
+ continue
245
+
246
+ usage: Optional[Dict[str, Any]] = None
247
+ source = "none"
248
+ fetched_at: Optional[str] = None
249
+ if running_container:
250
+ usage = _exec_usage_adapter(executor, running_container, since)
251
+ if usage is not None:
252
+ source = "live"
253
+ fetched_at = datetime.now().astimezone().isoformat(timespec="seconds")
254
+ try:
255
+ _write_cache(ws_dir.name, range_key, usage)
256
+ except OSError:
257
+ pass # cache is best-effort
258
+ if usage is None:
259
+ cached = _read_cache(ws_dir.name, range_key)
260
+ if cached is not None:
261
+ usage = cached
262
+ source = "cache"
263
+
264
+ providers = _normalize_providers(usage or {})
265
+ models = _normalize_models(usage or {})
266
+ for row in providers:
267
+ key = (row["app"], row["provider_id"])
268
+ agg = totals.setdefault(key, {
269
+ "app": row["app"], "provider_id": row["provider_id"],
270
+ "provider_name": row["provider_name"], "requests": 0,
271
+ "success": 0, "failed": 0, "tokens_total": 0,
272
+ "cost_estimate": 0.0, "currency": row["currency"],
273
+ })
274
+ agg["requests"] += row["requests"]
275
+ agg["success"] += row["success"]
276
+ agg["failed"] += row["failed"]
277
+ agg["tokens_total"] += row["tokens_total"]
278
+ agg["cost_estimate"] = round(agg["cost_estimate"] + row["cost_estimate"], 4)
279
+
280
+ workspaces.append({
281
+ "workspace_hash": ws_dir.name,
282
+ "workspace_path": ws_path,
283
+ "running": running_container is not None,
284
+ "container": running_container or "",
285
+ "source": source,
286
+ "fetched_at": fetched_at,
287
+ "available": bool(usage and usage.get("available")),
288
+ "providers": providers,
289
+ "models": models,
290
+ })
291
+
292
+ totals_rows = sorted(totals.values(),
293
+ key=lambda r: r["tokens_total"], reverse=True)
294
+ return {
295
+ "subscription": subscription,
296
+ "range": range_key,
297
+ "since": since,
298
+ "workspaces": workspaces,
299
+ "totals": {
300
+ "providers": totals_rows,
301
+ "requests": sum(r["requests"] for r in totals_rows),
302
+ "tokens_total": sum(r["tokens_total"] for r in totals_rows),
303
+ "cost_estimate": round(sum(r["cost_estimate"] for r in totals_rows), 4),
304
+ },
305
+ }