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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|