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,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()
|