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,245 @@
1
+ """Host-side application layer for the cc-switch Provider data plane (8d).
2
+
3
+ Mirrors ``aisc.application.provider``: resolves the running container for a
4
+ runtime, then ``docker exec -i``s the in-container adapter
5
+ (``aisc-cc-provider``) with ONE JSON request document on stdin. The adapter
6
+ speaks ``aisc.cc-switch-provider/v1``; this layer validates the envelope and
7
+ maps adapter failures onto CliError with the adapter's stable codes.
8
+
9
+ Secrets travel ONLY via the stdin document (D8-09) — the docker exec argv
10
+ never carries one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any, Dict, Optional
17
+
18
+ from aisc.application.runtime import resolve_running_container, validate_uuid_v4
19
+ from aisc.domain.models import CliError, RuntimeExitCode
20
+
21
+ PROTOCOL = "aisc.cc-switch-provider/v1"
22
+ _ADAPTER_PATH = "/usr/local/bin/aisc-cc-provider"
23
+ _AGENTS = ("claude", "codex")
24
+ _OPS = ("list", "add", "edit", "switch", "delete", "fetch-models")
25
+
26
+
27
+ def _validate(runtime_id: str, agent: str, op: str) -> None:
28
+ if not validate_uuid_v4(runtime_id):
29
+ raise CliError(
30
+ message=f"Invalid runtime ID (must be UUID v4): {runtime_id}",
31
+ exit_code=RuntimeExitCode.INVALID_RUNTIME_ID,
32
+ error_code=RuntimeExitCode.INVALID_RUNTIME_ID,
33
+ )
34
+ if agent not in _AGENTS:
35
+ raise CliError(
36
+ message=f"Invalid agent (must be claude|codex): {agent}",
37
+ exit_code=RuntimeExitCode.USAGE_ERROR,
38
+ error_code="AISC_ERR_INVALID_AGENT",
39
+ )
40
+ if op not in _OPS:
41
+ raise CliError(
42
+ message=f"Invalid cc-switch op: {op}",
43
+ exit_code=RuntimeExitCode.USAGE_ERROR,
44
+ error_code="AISC_ERR_USAGE",
45
+ )
46
+
47
+
48
+ def _exec_adapter(
49
+ runtime_id: str,
50
+ registry_root: Any,
51
+ executor: Any,
52
+ op: str,
53
+ agent: str,
54
+ provider_id: Optional[str],
55
+ request: Optional[Dict[str, Any]],
56
+ reveal_id: Optional[str] = None,
57
+ ) -> Dict[str, Any]:
58
+ """Run the in-container adapter and return the validated envelope."""
59
+ from aisc.adapters.docker_ import RealDockerExecutor
60
+
61
+ executor = executor or RealDockerExecutor()
62
+ argv = ["exec", "-i"]
63
+ container = resolve_running_container(runtime_id, executor, registry_root)
64
+ argv.append(container)
65
+ argv.extend(
66
+ [
67
+ "bash", "-c",
68
+ # Same scope wrapper as the TUI switch surface: the adapter needs
69
+ # the container's CC_SWITCH_CONFIG_DIR from PID 1's environ.
70
+ _SCOPE_WRAPPER,
71
+ "aisc-scope", "/proc/1/environ", "--",
72
+ _ADAPTER_PATH, op, "--agent", agent,
73
+ ]
74
+ )
75
+ if provider_id:
76
+ argv.extend(["--id", provider_id])
77
+ if reveal_id:
78
+ # 2.1.11 P1: edit-time explicit view — the named provider's row
79
+ # carries the FULL api_key (every other row stays masked).
80
+ argv.extend(["--reveal-id", reveal_id])
81
+
82
+ stdin_text = ""
83
+ if request is not None:
84
+ stdin_text = json.dumps(request, ensure_ascii=False)
85
+
86
+ # Docker `-i` + run_captured(input_text=…): the request (with any secret)
87
+ # reaches the adapter via stdin only — never argv, never disk (D8-09).
88
+ result = executor.run_captured(argv, timeout=60.0, input_text=stdin_text)
89
+ if result.exit_code != 0 and not (result.stdout or "").strip():
90
+ raise CliError(
91
+ message=f"cc-switch provider {op} failed: {(result.stderr or '').strip()[:200]}",
92
+ exit_code=RuntimeExitCode.PROVIDER_STATUS_FAILED,
93
+ error_code="AISC_ERR_CC_SWITCH_PROVIDER_EXEC_FAILED",
94
+ )
95
+ try:
96
+ envelope = json.loads((result.stdout or "").strip().splitlines()[-1])
97
+ except (json.JSONDecodeError, IndexError) as exc:
98
+ raise CliError(
99
+ message=f"invalid adapter output for {op}: {exc}",
100
+ exit_code=RuntimeExitCode.PROVIDER_STATUS_FAILED,
101
+ error_code="AISC_ERR_CC_SWITCH_PROVIDER_EXEC_FAILED",
102
+ ) from exc
103
+ if not isinstance(envelope, dict) or envelope.get("schema") != PROTOCOL:
104
+ raise CliError(
105
+ message=f"adapter envelope schema mismatch for {op}",
106
+ exit_code=RuntimeExitCode.PROVIDER_STATUS_FAILED,
107
+ error_code="AISC_ERR_CC_SWITCH_PROVIDER_EXEC_FAILED",
108
+ )
109
+ if not envelope.get("ok"):
110
+ error = envelope.get("error") or {}
111
+ raise CliError(
112
+ message=str(error.get("message") or f"cc-switch provider {op} failed"),
113
+ exit_code=1,
114
+ error_code=str(error.get("code") or "AISC_ERR_CC_SWITCH_PROVIDER_EXEC_FAILED"),
115
+ data={"providers": envelope.get("providers") or []},
116
+ )
117
+ return envelope
118
+
119
+
120
+ # The scope wrapper mirrors aisc.cli.commands.container._SCOPE_WRAPPER (kept
121
+ # in sync deliberately — importing the CLI layer from application would invert
122
+ # the dependency direction).
123
+ _SCOPE_WRAPPER = "\n".join([
124
+ 'if [ ! -r "$1" ]; then',
125
+ " echo 'Error: Cannot read scope environment from PID 1' >&2",
126
+ ' exit 101',
127
+ 'fi',
128
+ 'unset CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME',
129
+ "while IFS= read -r -d '' entry; do",
130
+ ' case "$entry" in',
131
+ ' CLAUDE_CONFIG_DIR=*) CLAUDE_CONFIG_DIR=${entry#*=} ;;',
132
+ ' CC_SWITCH_CONFIG_DIR=*) CC_SWITCH_CONFIG_DIR=${entry#*=} ;;',
133
+ ' CODEX_CONFIG_DIR=*) CODEX_CONFIG_DIR=${entry#*=} ;;',
134
+ ' CODEX_HOME=*) CODEX_HOME=${entry#*=} ;;',
135
+ ' esac',
136
+ 'done < "$1"',
137
+ 'shift',
138
+ 'shift',
139
+ 'if [ -z "${CLAUDE_CONFIG_DIR:-}" ] || [ -z "${CC_SWITCH_CONFIG_DIR:-}" ]; then',
140
+ " echo 'Error: Cannot read scope environment from PID 1' >&2",
141
+ ' exit 101',
142
+ 'fi',
143
+ 'export CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME',
144
+ 'exec "$@"',
145
+ '',
146
+ ])
147
+
148
+
149
+ def list_providers(runtime_id: str, agent: str, workspace: Optional[str], executor: Any,
150
+ reveal_id: Optional[str] = None) -> Dict[str, Any]:
151
+ from aisc.application.data_root import workspace_state_dir
152
+ from pathlib import Path
153
+
154
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
155
+ _validate(runtime_id, agent, "list")
156
+ envelope = _exec_adapter(
157
+ runtime_id, workspace_state_dir(ws_path), executor, "list", agent, None, None,
158
+ reveal_id=reveal_id,
159
+ )
160
+ return {"agent": agent, "providers": envelope.get("providers") or [],
161
+ "operation_id": envelope.get("operation_id")}
162
+
163
+
164
+ def add_provider(runtime_id: str, agent: str, request: Dict[str, Any],
165
+ workspace: Optional[str], executor: Any) -> Dict[str, Any]:
166
+ from aisc.application.data_root import workspace_state_dir
167
+ from pathlib import Path
168
+
169
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
170
+ _validate(runtime_id, agent, "add")
171
+ envelope = _exec_adapter(
172
+ runtime_id, workspace_state_dir(ws_path), executor, "add", agent,
173
+ request.get("id"), request,
174
+ )
175
+ return {"agent": agent, "providers": envelope.get("providers") or [],
176
+ "operation_id": envelope.get("operation_id")}
177
+
178
+
179
+ def edit_provider(runtime_id: str, agent: str, provider_id: str,
180
+ request: Dict[str, Any], workspace: Optional[str], executor: Any) -> Dict[str, Any]:
181
+ from aisc.application.data_root import workspace_state_dir
182
+ from pathlib import Path
183
+
184
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
185
+ _validate(runtime_id, agent, "edit")
186
+ envelope = _exec_adapter(
187
+ runtime_id, workspace_state_dir(ws_path), executor, "edit", agent,
188
+ provider_id, request,
189
+ )
190
+ return {"agent": agent, "providers": envelope.get("providers") or [],
191
+ "operation_id": envelope.get("operation_id")}
192
+
193
+
194
+ def switch_provider(runtime_id: str, agent: str, provider_id: str,
195
+ workspace: Optional[str], executor: Any) -> Dict[str, Any]:
196
+ from aisc.application.data_root import workspace_state_dir
197
+ from pathlib import Path
198
+
199
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
200
+ _validate(runtime_id, agent, "switch")
201
+ envelope = _exec_adapter(
202
+ runtime_id, workspace_state_dir(ws_path), executor, "switch", agent,
203
+ provider_id, None,
204
+ )
205
+ return {"agent": agent, "providers": envelope.get("providers") or [],
206
+ "operation_id": envelope.get("operation_id")}
207
+
208
+
209
+ def delete_provider(runtime_id: str, agent: str, provider_id: str,
210
+ workspace: Optional[str], executor: Any) -> Dict[str, Any]:
211
+ from aisc.application.data_root import workspace_state_dir
212
+ from pathlib import Path
213
+
214
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
215
+ _validate(runtime_id, agent, "delete")
216
+ envelope = _exec_adapter(
217
+ runtime_id, workspace_state_dir(ws_path), executor, "delete", agent,
218
+ provider_id, None,
219
+ )
220
+ return {"agent": agent, "providers": envelope.get("providers") or [],
221
+ "operation_id": envelope.get("operation_id")}
222
+
223
+
224
+ def fetch_models(runtime_id: str, agent: str, provider_id: Optional[str],
225
+ workspace: Optional[str], executor: Any,
226
+ request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
227
+ """Remote model list for the mapping dropdown (IDEA-5 5c).
228
+
229
+ Never raises on upstream fetch failures — the adapter degrades to
230
+ ``available=False`` with the upstream message so the UI can fall back
231
+ to the known list + manual input. ``request`` may carry the form's
232
+ unsaved ``api_key`` (PP r3) — it reaches the adapter via stdin only."""
233
+ from aisc.application.data_root import workspace_state_dir
234
+ from pathlib import Path
235
+
236
+ ws_path = Path(workspace).resolve() if workspace else Path.cwd()
237
+ _validate(runtime_id, agent, "fetch-models")
238
+ envelope = _exec_adapter(
239
+ runtime_id, workspace_state_dir(ws_path), executor, "fetch-models",
240
+ agent, provider_id or None, request,
241
+ )
242
+ result = envelope.get("fetch_models")
243
+ return result if isinstance(result, dict) else {
244
+ "available": False, "models": [], "message": "unexpected adapter payload",
245
+ }
@@ -0,0 +1,363 @@
1
+ """cc-switch release resolver — application layer (Stage 8b, CS-01).
2
+
3
+ Bridges the pure selection in ``aisc.domain.cc_switch_release`` to the real
4
+ world: GitHub Releases API (paginated, rate-limit aware, injectable transport
5
+ for tests), a TTL metadata cache under the shared data root (Stage 7 layout
6
+ ``<data-root>/cache/cc-switch/``), and offline builds from a previously
7
+ written manifest file.
8
+
9
+ Policies (03-build-version-resolution.md):
10
+ - ``latest`` resolves live; a fresh-enough cache may serve a network failure,
11
+ but the manifest always records ``source`` (api|cache|manifest) — a cached
12
+ resolution is surfaced, never silent;
13
+ - rate limiting never degrades into stale assets: with no usable cache the
14
+ resolve fails closed and points at the explicit-manifest path;
15
+ - an explicit ``vX.Y.Z`` may be served from a cache of any age (the pinned
16
+ release's digest is stable content), again with ``source=cache``;
17
+ - as the LAST offline resort (2.1.7 #37), the per-build receipt written after
18
+ every successful resolve (``cache/cc-switch/last-resolved.json``, any age —
19
+ a pinned digest is stable content) may serve a network/rate-limit failure,
20
+ same semantics as ``docker_lifecycle._cc_switch_for_rebuild``; a receipt
21
+ that does not match the request (channel/version/arch/libc) is never used.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import http.client
27
+ import json
28
+ import os
29
+ import time
30
+ import urllib.error
31
+ import urllib.request
32
+ from dataclasses import replace
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Any, Callable, Dict, List, Optional, Tuple
36
+
37
+ from aisc.domain.cc_switch_release import (
38
+ ResolveError,
39
+ ResolvedRelease,
40
+ SOURCE_API,
41
+ SOURCE_CACHE,
42
+ SOURCE_MANIFEST,
43
+ UPSTREAM_REPO,
44
+ CC_SWITCH_ERROR_BAD_MANIFEST,
45
+ CC_SWITCH_ERROR_NETWORK,
46
+ CC_SWITCH_ERROR_RATE_LIMITED,
47
+ )
48
+
49
+
50
+ DEFAULT_API_BASE = "https://api.github.com"
51
+ DEFAULT_TIMEOUT_S = 15.0
52
+ DEFAULT_TTL_S = 600.0
53
+ DEFAULT_PER_PAGE = 50
54
+ DEFAULT_MAX_PAGES = 4
55
+ CACHE_SCHEMA = "aisc.cc-switch-releases-cache/v1"
56
+
57
+ #: Injectable transport: (url, headers, timeout) -> (status, headers, body).
58
+ #: ``body`` is the parsed JSON (dict) on success. Raising propagates as a
59
+ #: network error except URLError/HTTPError which are mapped here.
60
+ Transport = Callable[[str, Dict[str, str], float], Tuple[int, Dict[str, str], Any]]
61
+
62
+
63
+ def default_transport(url: str, headers: Dict[str, str], timeout: float) -> Tuple[int, Dict[str, str], Any]:
64
+ """stdlib urllib transport (no extra dependency; PyInstaller-friendly)."""
65
+ req = urllib.request.Request(url, headers={"User-Agent": "aisc-resolver", **headers})
66
+ try:
67
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
68
+ raw = resp.read()
69
+ except urllib.error.HTTPError as exc:
70
+ raw = exc.read() if exc.fp else b""
71
+ try:
72
+ body: Any = json.loads(raw.decode("utf-8", errors="replace")) if raw else {}
73
+ except json.JSONDecodeError:
74
+ body = {"message": raw.decode("utf-8", errors="replace")}
75
+ return int(exc.code), {k.lower(): v for k, v in exc.headers.items()}, body
76
+ except (
77
+ urllib.error.URLError,
78
+ TimeoutError,
79
+ OSError,
80
+ # http.client.HTTPException covers IncompleteRead: a truncated body
81
+ # on a large releases listing (observed on flaky direct connections,
82
+ # 2026-08-31 — it used to escape as a raw crash instead of feeding
83
+ # the S8b fallback chain: online → cache → receipt → unpinned).
84
+ http.client.HTTPException,
85
+ ) as exc:
86
+ raise ResolveError(
87
+ CC_SWITCH_ERROR_NETWORK,
88
+ f"github api unreachable: {exc}",
89
+ ) from exc
90
+ try:
91
+ body = json.loads(raw.decode("utf-8"))
92
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
93
+ # A truncated-but-200 body parses as garbage — treat as network
94
+ # failure so the fallback chain (not a crash) handles it.
95
+ raise ResolveError(
96
+ CC_SWITCH_ERROR_NETWORK,
97
+ f"github api returned an unparseable (likely truncated) body: {exc}",
98
+ ) from exc
99
+ return int(resp.status), {k.lower(): v for k, v in resp.headers.items()}, body
100
+
101
+
102
+ def _cache_dir() -> Path:
103
+ # Local import keeps the module importable without a resolved data root
104
+ # (domain selection and manifest loading never touch the cache).
105
+ from aisc.application.data_root import shared_root
106
+
107
+ return shared_root() / "cache" / "cc-switch"
108
+
109
+
110
+ def _utc_now_iso() -> str:
111
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
112
+
113
+
114
+ class CcSwitchResolver:
115
+ """Resolves the cc-switch release for a build.
116
+
117
+ Args mirror the CLI knobs; every I/O seam (transport, cache dir, clock)
118
+ is injectable so the fake-API test matrix in
119
+ ``tests/test_cc_switch_resolver.py`` covers pagination, rate limits,
120
+ cache fallback and offline manifests without network access.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ *,
126
+ transport: Transport = default_transport,
127
+ cache_dir: Optional[Path] = None,
128
+ api_base: Optional[str] = None,
129
+ token: Optional[str] = None,
130
+ timeout: float = DEFAULT_TIMEOUT_S,
131
+ ttl_s: float = DEFAULT_TTL_S,
132
+ clock: Callable[[], float] = time.time,
133
+ ):
134
+ self._transport = transport
135
+ self._cache_dir = Path(cache_dir) if cache_dir is not None else None
136
+ self._api_base = (api_base or os.environ.get("AISC_GH_API_BASE") or DEFAULT_API_BASE).rstrip("/")
137
+ self._token = token or os.environ.get("AISC_GH_API_TOKEN") or ""
138
+ self._timeout = timeout
139
+ self._ttl_s = ttl_s
140
+ self._clock = clock
141
+
142
+ # --- public API ---------------------------------------------------------
143
+
144
+ def resolve(
145
+ self,
146
+ *,
147
+ channel: str = "stable",
148
+ version: str = "latest",
149
+ arch: str = "x64",
150
+ libc: str = "musl",
151
+ manifest_path: Optional[Path] = None,
152
+ ) -> ResolvedRelease:
153
+ """Resolve to a :class:`ResolvedRelease` (fail closed).
154
+
155
+ ``manifest_path`` short-circuits all network/cache I/O: the file is
156
+ strictly validated and cross-checked against the request.
157
+ """
158
+ if manifest_path is not None:
159
+ return self._from_manifest(manifest_path, channel, version)
160
+
161
+ try:
162
+ releases = self._fetch_releases()
163
+ except ResolveError as network_error:
164
+ if network_error.code not in (CC_SWITCH_ERROR_NETWORK, CC_SWITCH_ERROR_RATE_LIMITED):
165
+ raise
166
+ cached = self._cached_resolve(channel, version, arch, libc)
167
+ if cached is not None:
168
+ return cached
169
+ # 2.1.7 #37: last resort — the receipt from ANY previous
170
+ # successful resolve (any age, digest-stable pin).
171
+ receipt = self._last_resolved_fallback(channel, version, arch, libc)
172
+ if receipt is not None:
173
+ return receipt
174
+ raise ResolveError(
175
+ network_error.code,
176
+ f"{network_error.message} "
177
+ f"(no usable cache — check network/proxy access to "
178
+ f"api.github.com and retry; for fully offline builds pass a "
179
+ f"manifest file written by a previous resolve via "
180
+ f"--cc-switch-manifest)",
181
+ ) from network_error
182
+
183
+ selection = self._select(releases, channel, version, arch, libc, source=SOURCE_API)
184
+ self._write_cache(releases)
185
+ return selection
186
+
187
+ # --- manifest (offline) --------------------------------------------------
188
+
189
+ def _from_manifest(self, path: Path, channel: str, version: str) -> ResolvedRelease:
190
+ try:
191
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
192
+ except (OSError, json.JSONDecodeError) as exc:
193
+ raise ResolveError(
194
+ CC_SWITCH_ERROR_BAD_MANIFEST,
195
+ f"cannot read cc-switch manifest {path}: {exc}",
196
+ ) from exc
197
+ resolved = ResolvedRelease.from_manifest(data)
198
+ resolved.check_matches_request(channel, version)
199
+ # A manifest build never claims to be live.
200
+ return replace(resolved, source=SOURCE_MANIFEST)
201
+
202
+ # --- GitHub API ----------------------------------------------------------
203
+
204
+ def _fetch_page(self, page: int) -> Tuple[List[Dict[str, Any]], bool]:
205
+ url = (
206
+ f"{self._api_base}/repos/{UPSTREAM_REPO}/releases"
207
+ f"?per_page={DEFAULT_PER_PAGE}&page={page}"
208
+ )
209
+ headers: Dict[str, str] = {"Accept": "application/vnd.github+json"}
210
+ if self._token:
211
+ headers["Authorization"] = f"Bearer {self._token}"
212
+ status, resp_headers, body = self._transport(url, headers, self._timeout)
213
+
214
+ if status in (403, 429):
215
+ remaining = str(resp_headers.get("x-ratelimit-remaining", ""))
216
+ if status == 429 or remaining == "0":
217
+ raise ResolveError(
218
+ CC_SWITCH_ERROR_RATE_LIMITED,
219
+ "github api rate limited (pass --cc-switch-manifest for an offline build)",
220
+ )
221
+ raise ResolveError(
222
+ CC_SWITCH_ERROR_NETWORK,
223
+ f"github api refused (HTTP {status}): {body.get('message', '')!s:.120}",
224
+ )
225
+ if status != 200:
226
+ raise ResolveError(
227
+ CC_SWITCH_ERROR_NETWORK,
228
+ f"github api error (HTTP {status})",
229
+ )
230
+ if not isinstance(body, list):
231
+ raise ResolveError(
232
+ CC_SWITCH_ERROR_NETWORK,
233
+ "github api returned a non-list releases payload",
234
+ )
235
+ return body, len(body) < DEFAULT_PER_PAGE
236
+
237
+ def _fetch_releases(self) -> List[Dict[str, Any]]:
238
+ all_releases: List[Dict[str, Any]] = []
239
+ for page in range(1, DEFAULT_MAX_PAGES + 1):
240
+ page_releases, last = self._fetch_page(page)
241
+ all_releases.extend(page_releases)
242
+ if last or not page_releases:
243
+ break
244
+ else:
245
+ # Exhausted pagination without finding a terminal page; keep what
246
+ # we have — selection fails closed if the target isn't in range.
247
+ pass
248
+ return all_releases
249
+
250
+ # --- cache ---------------------------------------------------------------
251
+
252
+ @property
253
+ def _cache_file(self) -> Optional[Path]:
254
+ return (self._cache_dir or _cache_dir()) / "releases.json"
255
+
256
+ def _write_cache(self, releases: List[Dict[str, Any]]) -> None:
257
+ path = self._cache_file
258
+ if path is None:
259
+ return
260
+ try:
261
+ path.parent.mkdir(parents=True, exist_ok=True)
262
+ payload = {
263
+ "schema": CACHE_SCHEMA,
264
+ "fetched_at": self._clock(),
265
+ "repo": UPSTREAM_REPO,
266
+ "releases": releases,
267
+ }
268
+ tmp = path.with_suffix(".json.tmp")
269
+ tmp.write_text(json.dumps(payload), encoding="utf-8")
270
+ tmp.replace(path)
271
+ except OSError:
272
+ # Cache is an optimization, never a gate.
273
+ pass
274
+
275
+ def _read_cache(self) -> Optional[Tuple[float, List[Dict[str, Any]]]]:
276
+ path = self._cache_file
277
+ if path is None or not path.exists():
278
+ return None
279
+ try:
280
+ payload = json.loads(path.read_text(encoding="utf-8"))
281
+ if payload.get("schema") != CACHE_SCHEMA:
282
+ return None
283
+ fetched_at = float(payload["fetched_at"])
284
+ releases = payload["releases"]
285
+ if not isinstance(releases, list):
286
+ return None
287
+ return fetched_at, releases
288
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
289
+ return None
290
+
291
+ def _cached_resolve(
292
+ self, channel: str, version: str, arch: str, libc: str
293
+ ) -> Optional[ResolvedRelease]:
294
+ cached = self._read_cache()
295
+ if cached is None:
296
+ return None
297
+ fetched_at, releases = cached
298
+ age = self._clock() - fetched_at
299
+ # latest only trusts a fresh cache; an explicit version is pinned, so
300
+ # any age is acceptable (source=cache still surfaces the fact).
301
+ if version == "latest" and age > self._ttl_s:
302
+ return None
303
+ try:
304
+ return self._select(releases, channel, version, arch, libc, source=SOURCE_CACHE)
305
+ except ResolveError:
306
+ return None
307
+
308
+ @property
309
+ def _last_resolved_file(self) -> Path:
310
+ # The per-build receipt ``aisc build`` writes after every successful
311
+ # resolve (cli.main._write_cc_switch_manifest — same shared-root
312
+ # cache dir as ``releases.json``).
313
+ return (self._cache_dir or _cache_dir()) / "last-resolved.json"
314
+
315
+ def _last_resolved_fallback(
316
+ self, channel: str, version: str, arch: str, libc: str
317
+ ) -> Optional[ResolvedRelease]:
318
+ """Offline last resort (2.1.7 #37): serve the request from the
319
+ last-resolved receipt, ANY age (a pinned digest is stable content —
320
+ same semantics as ``_cc_switch_for_rebuild``).
321
+
322
+ Strictly fail-closed on mismatch: a receipt that does not match the
323
+ requested channel/version/arch/libc must never silently build the
324
+ wrong pin, so it reads as "no fallback" and the network error
325
+ propagates. The served resolution keeps ``source=cache`` — surfaced,
326
+ never silent."""
327
+ try:
328
+ data = json.loads(self._last_resolved_file.read_text(encoding="utf-8"))
329
+ resolved = ResolvedRelease.from_manifest(data)
330
+ resolved.check_matches_request(channel, version)
331
+ if resolved.arch != arch or resolved.libc != libc:
332
+ return None
333
+ except (OSError, ValueError, ResolveError):
334
+ return None
335
+ # A receipt never masquerades as a live resolution.
336
+ return replace(resolved, source=SOURCE_CACHE)
337
+
338
+ # --- selection wrapper -----------------------------------------------------
339
+
340
+ def _select(
341
+ self,
342
+ releases: List[Dict[str, Any]],
343
+ channel: str,
344
+ version: str,
345
+ arch: str,
346
+ libc: str,
347
+ *,
348
+ source: str,
349
+ ) -> ResolvedRelease:
350
+ # Local import: domain module has no application deps.
351
+ from aisc.domain.cc_switch_release import resolved_from_release, select_release
352
+
353
+ release, asset = select_release(
354
+ releases, channel=channel, version=version, arch=arch, libc=libc
355
+ )
356
+ return resolved_from_release(
357
+ release, asset, channel=channel, arch=arch, libc=libc, source=source
358
+ )
359
+
360
+
361
+ def resolved_at_now() -> str:
362
+ """Timestamp stamped into manifests written by the build command."""
363
+ return _utc_now_iso()