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,320 @@
|
|
|
1
|
+
"""Session application logic for Workbench Phase 0 S0.3.
|
|
2
|
+
|
|
3
|
+
Implements the business logic behind ``aisc session open/list/terminate``.
|
|
4
|
+
Per docs/gui-planning/05-cli-gui-contract.md §6.
|
|
5
|
+
|
|
6
|
+
Key design:
|
|
7
|
+
- ``open_session`` is interactive (``docker exec -it``), returns a
|
|
8
|
+
``ProcessResult`` whose ``exit_code`` is the agent's exit code.
|
|
9
|
+
- ``list_sessions`` reads in-container session metadata via ``docker exec``
|
|
10
|
+
(non-interactive, captured stdout).
|
|
11
|
+
- ``terminate_session`` runs the wrapper's ``--terminate`` mode via
|
|
12
|
+
``docker exec`` (non-interactive, captured stdout).
|
|
13
|
+
|
|
14
|
+
The container-side logic (env rebuild, process group management, PID
|
|
15
|
+
identity check, session record) lives in ``container/aisc-session-wrapper``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from aisc.application.runtime import resolve_running_container, validate_uuid_v4
|
|
27
|
+
from aisc.domain.models import (
|
|
28
|
+
CliError,
|
|
29
|
+
RuntimeErrorCode,
|
|
30
|
+
RuntimeExitCode,
|
|
31
|
+
SessionAgent,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Validation helpers
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
_SESSION_WRAPPER_PATH = "/usr/local/bin/aisc-session-wrapper"
|
|
40
|
+
_SESSIONS_DIR = "/run/aisc/sessions"
|
|
41
|
+
|
|
42
|
+
# Reuse the same UUID v4 pattern as runtime_id.
|
|
43
|
+
_UUID_V4_RE = re.compile(
|
|
44
|
+
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_session_id(session_id: str) -> bool:
|
|
50
|
+
"""Validate that *session_id* is a strict UUID v4."""
|
|
51
|
+
return bool(_UUID_V4_RE.match(session_id))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_agent(agent: str) -> bool:
|
|
55
|
+
"""Validate that *agent* is one of the four controlled values."""
|
|
56
|
+
return agent in SessionAgent.ALL
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Container resolution (shared with provider; implementation in application.runtime)
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
# Kept as a private alias so this module's callers and tests are unaffected by
|
|
64
|
+
# the extraction of ``resolve_running_container`` to application.runtime.
|
|
65
|
+
_resolve_running_container = resolve_running_container
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Session open (interactive)
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def build_session_exec(
|
|
73
|
+
runtime_id: str,
|
|
74
|
+
session_id: str,
|
|
75
|
+
agent: str,
|
|
76
|
+
executor: Any,
|
|
77
|
+
registry_root: Any,
|
|
78
|
+
resume_conversation_id: Optional[str] = None,
|
|
79
|
+
) -> Tuple[str, List[str], Optional[Dict[str, str]]]:
|
|
80
|
+
"""Resolve + validate + construct the exec for one interactive session.
|
|
81
|
+
|
|
82
|
+
2.1.10 R2 (D-8): the shared middle of session-open — the pipe-mode
|
|
83
|
+
``open_session`` and the serve PTY op both call this, then attach their
|
|
84
|
+
own transports (inherited-fd SDK session vs stream handle).
|
|
85
|
+
|
|
86
|
+
Returns ``(container_name, docker_argv, env)``; raises ``CliError`` on
|
|
87
|
+
validation/resolution failures (identical codes to the one-shot CLI).
|
|
88
|
+
"""
|
|
89
|
+
if not validate_uuid_v4(runtime_id):
|
|
90
|
+
raise CliError(
|
|
91
|
+
message=f"Invalid runtime ID (must be UUID v4): {runtime_id}",
|
|
92
|
+
exit_code=RuntimeExitCode.INVALID_RUNTIME_ID,
|
|
93
|
+
error_code=RuntimeErrorCode.INVALID_RUNTIME_ID,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if not validate_session_id(session_id):
|
|
97
|
+
raise CliError(
|
|
98
|
+
message=f"Invalid session ID (must be UUID v4): {session_id}",
|
|
99
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
100
|
+
error_code=RuntimeErrorCode.INVALID_SESSION_ID,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not validate_agent(agent):
|
|
104
|
+
raise CliError(
|
|
105
|
+
message=f"Invalid agent (must be one of {', '.join(SessionAgent.ALL)}): {agent}",
|
|
106
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
107
|
+
error_code=RuntimeErrorCode.INVALID_AGENT,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
container_name = _resolve_running_container(runtime_id, executor, registry_root)
|
|
111
|
+
|
|
112
|
+
docker_argv = [
|
|
113
|
+
_SESSION_WRAPPER_PATH,
|
|
114
|
+
"open",
|
|
115
|
+
"--session-id", session_id,
|
|
116
|
+
"--runtime-id", runtime_id,
|
|
117
|
+
"--agent", agent,
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
if resume_conversation_id is not None:
|
|
121
|
+
from aisc.application.conversation import (
|
|
122
|
+
ERROR_INVALID_AGENT as _CONV_INVALID_AGENT,
|
|
123
|
+
is_conversation_uuid,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if not is_conversation_uuid(resume_conversation_id):
|
|
127
|
+
raise CliError(
|
|
128
|
+
message=f"Invalid conversation ID: {resume_conversation_id}",
|
|
129
|
+
exit_code=2,
|
|
130
|
+
error_code="AISC_ERR_CONVERSATION_INVALID_ID",
|
|
131
|
+
)
|
|
132
|
+
if agent not in ("claude", "codex"):
|
|
133
|
+
raise CliError(
|
|
134
|
+
message=f"agent does not support --resume-id: {agent}",
|
|
135
|
+
exit_code=2,
|
|
136
|
+
error_code=_CONV_INVALID_AGENT,
|
|
137
|
+
)
|
|
138
|
+
docker_argv += ["--resume-id", resume_conversation_id]
|
|
139
|
+
|
|
140
|
+
# v2.1.7 S6: bash sessions import the tutorial `help` function from the
|
|
141
|
+
# exec environment (bash re-imports BASH_FUNC_* vars at startup; the
|
|
142
|
+
# wrapper passes its os.environ through). Nothing is written to the
|
|
143
|
+
# image, any profile, or the user's workspace (A-21766), and non-bash
|
|
144
|
+
# agents are untouched.
|
|
145
|
+
env = None
|
|
146
|
+
if agent == "bash":
|
|
147
|
+
from aisc.cli.tutorial import help_function_env
|
|
148
|
+
|
|
149
|
+
env = help_function_env()
|
|
150
|
+
|
|
151
|
+
return container_name, docker_argv, env
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def open_session(
|
|
155
|
+
runtime_id: str,
|
|
156
|
+
session_id: str,
|
|
157
|
+
agent: str,
|
|
158
|
+
executor: Any,
|
|
159
|
+
registry_root: Any,
|
|
160
|
+
resume_conversation_id: Optional[str] = None,
|
|
161
|
+
) -> Any:
|
|
162
|
+
"""Open an interactive agent session via ``docker exec -it``.
|
|
163
|
+
|
|
164
|
+
Per contract §6.1:
|
|
165
|
+
- Validates runtime_id, session_id (UUID v4) and agent (controlled enum).
|
|
166
|
+
- Checks runtime exists and is running.
|
|
167
|
+
- Constructs a controlled argv (no shell=True) to execute the
|
|
168
|
+
in-container ``aisc-session-wrapper``.
|
|
169
|
+
- Runs with inherited stdio (interactive TTY); the agent's exit code
|
|
170
|
+
becomes the process exit code.
|
|
171
|
+
|
|
172
|
+
Returns a ``ProcessResult`` from ``executor.run_streaming``.
|
|
173
|
+
"""
|
|
174
|
+
container_name, docker_argv, env = build_session_exec(
|
|
175
|
+
runtime_id=runtime_id,
|
|
176
|
+
session_id=session_id,
|
|
177
|
+
agent=agent,
|
|
178
|
+
executor=executor,
|
|
179
|
+
registry_root=registry_root,
|
|
180
|
+
resume_conversation_id=resume_conversation_id,
|
|
181
|
+
)
|
|
182
|
+
return executor.open_interactive(container_name, docker_argv, env=env)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Session list (non-interactive, captured)
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def list_sessions(
|
|
190
|
+
runtime_id: str,
|
|
191
|
+
executor: Any,
|
|
192
|
+
registry_root: Any,
|
|
193
|
+
) -> List[Dict[str, Any]]:
|
|
194
|
+
"""List sessions for a runtime by reading in-container metadata.
|
|
195
|
+
|
|
196
|
+
Per contract §6.2: reads ``/run/aisc/sessions/*.json`` inside the
|
|
197
|
+
container via ``docker exec`` (non-interactive, captured stdout).
|
|
198
|
+
Only used for diagnostics and crash cleanup; never claims PTY
|
|
199
|
+
recoverability.
|
|
200
|
+
"""
|
|
201
|
+
container_name = _resolve_running_container(runtime_id, executor, registry_root)
|
|
202
|
+
|
|
203
|
+
docker_argv = [
|
|
204
|
+
"exec", container_name,
|
|
205
|
+
_SESSION_WRAPPER_PATH,
|
|
206
|
+
"list",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
result = executor.run_captured(docker_argv, timeout=15.0)
|
|
210
|
+
|
|
211
|
+
if result.exit_code != 0:
|
|
212
|
+
raise CliError(
|
|
213
|
+
message=f"Failed to list sessions: {(result.stderr or '').strip()[:200]}",
|
|
214
|
+
exit_code=RuntimeExitCode.SESSION_FAILED,
|
|
215
|
+
error_code=RuntimeErrorCode.SESSION_FAILED,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
stdout = result.stdout.strip()
|
|
219
|
+
if not stdout:
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
data = json.loads(stdout)
|
|
224
|
+
except json.JSONDecodeError as exc:
|
|
225
|
+
raise CliError(
|
|
226
|
+
message=f"Invalid session list output: {exc}",
|
|
227
|
+
exit_code=RuntimeExitCode.SESSION_FAILED,
|
|
228
|
+
error_code=RuntimeErrorCode.SESSION_FAILED,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if not isinstance(data, list):
|
|
232
|
+
raise CliError(
|
|
233
|
+
message="Session list output is not a JSON array",
|
|
234
|
+
exit_code=RuntimeExitCode.SESSION_FAILED,
|
|
235
|
+
error_code=RuntimeErrorCode.SESSION_FAILED,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return data
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Session terminate (non-interactive, captured)
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def terminate_session(
|
|
246
|
+
runtime_id: str,
|
|
247
|
+
session_id: str,
|
|
248
|
+
executor: Any,
|
|
249
|
+
registry_root: Any,
|
|
250
|
+
*,
|
|
251
|
+
grace_seconds: float = 5.0,
|
|
252
|
+
) -> Dict[str, Any]:
|
|
253
|
+
"""Terminate a session by running the wrapper's ``--terminate`` mode.
|
|
254
|
+
|
|
255
|
+
Per contract §6.2:
|
|
256
|
+
- Sends TERM to the session process group, waits a bounded grace period,
|
|
257
|
+
then KILL if still alive.
|
|
258
|
+
- Uses PID/PGID/start-ticks identity check to avoid killing a reused PID.
|
|
259
|
+
- Idempotent: terminating an already-exited session succeeds.
|
|
260
|
+
- Returns the terminal session record.
|
|
261
|
+
"""
|
|
262
|
+
if not validate_uuid_v4(runtime_id):
|
|
263
|
+
raise CliError(
|
|
264
|
+
message=f"Invalid runtime ID (must be UUID v4): {runtime_id}",
|
|
265
|
+
exit_code=RuntimeExitCode.INVALID_RUNTIME_ID,
|
|
266
|
+
error_code=RuntimeErrorCode.INVALID_RUNTIME_ID,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if not validate_session_id(session_id):
|
|
270
|
+
raise CliError(
|
|
271
|
+
message=f"Invalid session ID (must be UUID v4): {session_id}",
|
|
272
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
273
|
+
error_code=RuntimeErrorCode.INVALID_SESSION_ID,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# --grace must be finite and in 0..600; reject NaN/Infinity/out-of-range
|
|
277
|
+
# before any Docker call (05 §4.2).
|
|
278
|
+
if not isinstance(grace_seconds, (int, float)) or not math.isfinite(grace_seconds) or not 0 <= grace_seconds <= 600:
|
|
279
|
+
raise CliError(
|
|
280
|
+
message=f"Invalid --grace {grace_seconds!r}: must be a finite number in 0..600",
|
|
281
|
+
exit_code=2,
|
|
282
|
+
error_code="AISC_ERR_USAGE",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
container_name = _resolve_running_container(runtime_id, executor, registry_root)
|
|
286
|
+
|
|
287
|
+
docker_argv = [
|
|
288
|
+
"exec", container_name,
|
|
289
|
+
_SESSION_WRAPPER_PATH,
|
|
290
|
+
"terminate",
|
|
291
|
+
"--session-id", session_id,
|
|
292
|
+
"--runtime-id", runtime_id,
|
|
293
|
+
"--grace", str(grace_seconds),
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
# Outer transport budget is grace + 1s (05 §4.2): keeps the Workbench
|
|
297
|
+
# --grace 3 path inside its 5s command budget while leaving TERM->KILL
|
|
298
|
+
# grace untouched.
|
|
299
|
+
result = executor.run_captured(docker_argv, timeout=grace_seconds + 1.0)
|
|
300
|
+
|
|
301
|
+
if result.exit_code != 0:
|
|
302
|
+
raise CliError(
|
|
303
|
+
message=f"Failed to terminate session: {(result.stderr or '').strip()[:200]}",
|
|
304
|
+
exit_code=RuntimeExitCode.SESSION_FAILED,
|
|
305
|
+
error_code=RuntimeErrorCode.SESSION_FAILED,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
stdout = result.stdout.strip()
|
|
309
|
+
if not stdout:
|
|
310
|
+
# Wrapper may return empty on success (already exited, record cleaned).
|
|
311
|
+
return {"session_id": session_id, "state": "exited", "exit_code": None}
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
data = json.loads(stdout)
|
|
315
|
+
if isinstance(data, dict):
|
|
316
|
+
return data
|
|
317
|
+
except json.JSONDecodeError:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
return {"session_id": session_id, "state": "exited", "exit_code": None}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Tool pin resolution + user-layer overlay (0.1.0 A5, D-9/D-27).
|
|
2
|
+
|
|
3
|
+
Two-layer truth for the container tool versions:
|
|
4
|
+
|
|
5
|
+
- FACTORY layer: ``config/versions.env`` inside the resolved root (repo
|
|
6
|
+
checkout / installed aisc-bundle / pip ``bundles/<ver>/``). Read-only —
|
|
7
|
+
it is part of the shipped bundle and every update path replaces it.
|
|
8
|
+
- USER layer: ``<data-root>/config/versions.env`` — the ONE place
|
|
9
|
+
``aisc update --pin-tool`` writes. Survives CLI updates, NSIS upgrades
|
|
10
|
+
and bundle fetches by construction (none of them touch the data root's
|
|
11
|
+
config/). ``AISC_DATA_ROOT`` knob applies via ``shared_root()``.
|
|
12
|
+
|
|
13
|
+
Resolution: factory values as the base, user layer overrides KEY-WISE
|
|
14
|
+
(only keys the user bumped). The r4 incident (pins silently downgraded by
|
|
15
|
+
a bundle refresh) is structurally impossible under this split.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
#: Keys a user may pin (claude/codex are npm-driven; cc-switch's resolver
|
|
26
|
+
#: pin is the manual-fallback value only; NODE_IMAGE stays factory — the CN
|
|
27
|
+
#: mirror chain pairs with it and a bare image override would desync them).
|
|
28
|
+
PINNABLE_TOOL_KEYS = ("CLAUDE_CODE_VERSION", "CODEX_VERSION", "CC_SWITCH_VERSION")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_env_file(path: Path) -> Dict[str, str]:
|
|
32
|
+
out: Dict[str, str] = {}
|
|
33
|
+
if not path.is_file():
|
|
34
|
+
return out
|
|
35
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
36
|
+
s = line.strip()
|
|
37
|
+
if not s or s.startswith("#"):
|
|
38
|
+
continue
|
|
39
|
+
if "=" not in s:
|
|
40
|
+
continue
|
|
41
|
+
k, v = s.split("=", 1)
|
|
42
|
+
v = v.split("#", 1)[0].strip() # strip trailing comments
|
|
43
|
+
if k.strip() and v:
|
|
44
|
+
out[k.strip()] = v
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def user_pin_path(data_root: Path) -> Path:
|
|
49
|
+
return data_root / "config" / "versions.env"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def effective_tool_versions(
|
|
53
|
+
root: Path,
|
|
54
|
+
data_root: Optional[Path] = None,
|
|
55
|
+
) -> Dict[str, Tuple[str, str]]:
|
|
56
|
+
"""Resolve each factory key with its source: key -> (value, source).
|
|
57
|
+
|
|
58
|
+
source is ``"user"`` (user-layer override won) or ``"default"``
|
|
59
|
+
(factory value). Only keys present in the FACTORY file participate —
|
|
60
|
+
the user layer cannot invent keys the image build never reads.
|
|
61
|
+
"""
|
|
62
|
+
factory = _parse_env_file(root / "config" / "versions.env")
|
|
63
|
+
user: Dict[str, str] = {}
|
|
64
|
+
if data_root is None:
|
|
65
|
+
try:
|
|
66
|
+
from aisc.application.data_root import shared_root
|
|
67
|
+
|
|
68
|
+
data_root = shared_root()
|
|
69
|
+
except Exception:
|
|
70
|
+
data_root = None
|
|
71
|
+
if data_root is not None:
|
|
72
|
+
user = _parse_env_file(user_pin_path(data_root))
|
|
73
|
+
out: Dict[str, Tuple[str, str]] = {}
|
|
74
|
+
for key, value in factory.items():
|
|
75
|
+
if key in user:
|
|
76
|
+
out[key] = (user[key], "user")
|
|
77
|
+
else:
|
|
78
|
+
out[key] = (value, "default")
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_user_pins(
|
|
83
|
+
data_root: Path,
|
|
84
|
+
pins: Dict[str, str],
|
|
85
|
+
) -> Path:
|
|
86
|
+
"""Merge *pins* into the user-layer versions.env (comment-preserving,
|
|
87
|
+
atomic tmp+rename). Returns the written path. Validates keys against
|
|
88
|
+
PINNABLE_TOOL_KEYS (raises ValueError listing the offenders)."""
|
|
89
|
+
unknown = sorted(k for k in pins if k not in PINNABLE_TOOL_KEYS)
|
|
90
|
+
if unknown:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"not pinnable: {', '.join(unknown)} "
|
|
93
|
+
f"(pinnable: {', '.join(PINNABLE_TOOL_KEYS)})")
|
|
94
|
+
path = user_pin_path(data_root)
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
lines: List[str] = []
|
|
98
|
+
if path.is_file():
|
|
99
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
100
|
+
# Update in place when the key exists on its own line; collect misses.
|
|
101
|
+
seen = set()
|
|
102
|
+
updated: List[str] = []
|
|
103
|
+
for line in lines:
|
|
104
|
+
stripped = line.strip()
|
|
105
|
+
if stripped and not stripped.startswith("#") and "=" in stripped:
|
|
106
|
+
k = stripped.split("=", 1)[0].strip()
|
|
107
|
+
if k in pins:
|
|
108
|
+
updated.append(f"{k}={pins[k]}")
|
|
109
|
+
seen.add(k)
|
|
110
|
+
continue
|
|
111
|
+
updated.append(line)
|
|
112
|
+
header_needed = not updated or updated[0].strip() != "# AISC user tool pins (aisc update --pin-tool; overrides the bundle's factory defaults)"
|
|
113
|
+
if header_needed and not any("AISC user tool pins" in l for l in updated):
|
|
114
|
+
updated.insert(0, "# AISC user tool pins (aisc update --pin-tool; overrides the bundle's factory defaults)")
|
|
115
|
+
for k, v in pins.items():
|
|
116
|
+
if k not in seen:
|
|
117
|
+
updated.append(f"{k}={v}")
|
|
118
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=".versions-", suffix=".env")
|
|
119
|
+
os.close(fd)
|
|
120
|
+
tmp = Path(tmp_name)
|
|
121
|
+
try:
|
|
122
|
+
tmp.write_text("\n".join(updated) + "\n", encoding="utf-8", newline="")
|
|
123
|
+
os.replace(tmp, path)
|
|
124
|
+
finally:
|
|
125
|
+
if tmp.exists():
|
|
126
|
+
tmp.unlink()
|
|
127
|
+
return path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Latest-available lookups (network-tolerant; injectable for tests)
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
NPM_MIRROR = "https://registry.npmmirror.com"
|
|
135
|
+
NPM_PACKAGES = {
|
|
136
|
+
"CLAUDE_CODE_VERSION": "@anthropic-ai/claude-code",
|
|
137
|
+
"CODEX_VERSION": "@openai/codex",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _npm_latest(pkg: str, opener: Callable[[str], dict]) -> Optional[dict]:
|
|
142
|
+
"""One registry metadata read via *opener*(url) -> parsed json dict."""
|
|
143
|
+
try:
|
|
144
|
+
d = opener(f"{NPM_MIRROR}/{pkg}")
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
if not isinstance(d, dict):
|
|
148
|
+
return None
|
|
149
|
+
latest = d.get("dist-tags", {}).get("latest")
|
|
150
|
+
if not latest:
|
|
151
|
+
return None
|
|
152
|
+
engines = (d.get("versions", {}) or {}).get(latest, {}).get("engines") or {}
|
|
153
|
+
return {"latest": latest, "node": engines.get("node") or ""}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def default_opener(url: str) -> dict:
|
|
157
|
+
import json
|
|
158
|
+
import urllib.request
|
|
159
|
+
|
|
160
|
+
req = urllib.request.Request(url, headers={"User-Agent": "aisc-update-check"})
|
|
161
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
162
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def tools_check_section(
|
|
166
|
+
root: Path,
|
|
167
|
+
data_root: Optional[Path] = None,
|
|
168
|
+
opener: Callable[[str], dict] = default_opener,
|
|
169
|
+
) -> List[dict]:
|
|
170
|
+
"""The `--check` tools block: effective pin (value + source) and the
|
|
171
|
+
latest available version per tool. Registry failures degrade to
|
|
172
|
+
``latest: null`` — a check must never fail because the network did."""
|
|
173
|
+
eff = effective_tool_versions(root, data_root)
|
|
174
|
+
out: List[dict] = []
|
|
175
|
+
for key, pkg in NPM_PACKAGES.items():
|
|
176
|
+
value, source = eff.get(key, ("latest", "default"))
|
|
177
|
+
info = _npm_latest(pkg, opener)
|
|
178
|
+
out.append({
|
|
179
|
+
"tool": key,
|
|
180
|
+
"current": value,
|
|
181
|
+
"source": source,
|
|
182
|
+
"latest": info["latest"] if info else None,
|
|
183
|
+
"node_requirement": info["node"] if info else None,
|
|
184
|
+
})
|
|
185
|
+
cc_value, cc_source = eff.get("CC_SWITCH_VERSION", ("latest", "default"))
|
|
186
|
+
cc_latest = _cached_cc_switch_latest()
|
|
187
|
+
out.append({
|
|
188
|
+
"tool": "CC_SWITCH_VERSION",
|
|
189
|
+
"current": cc_value,
|
|
190
|
+
"source": cc_source,
|
|
191
|
+
"latest": cc_latest,
|
|
192
|
+
"node_requirement": "",
|
|
193
|
+
})
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _cached_cc_switch_latest() -> Optional[str]:
|
|
198
|
+
"""Latest stable cc-switch from the resolver cache (offline-tolerant;
|
|
199
|
+
a cache-less machine reports null — the build-time resolver remains
|
|
200
|
+
the authoritative live source)."""
|
|
201
|
+
try:
|
|
202
|
+
import json
|
|
203
|
+
|
|
204
|
+
from aisc.application.data_root import shared_root
|
|
205
|
+
|
|
206
|
+
f = shared_root() / "cache" / "cc-switch" / "last-resolved.json"
|
|
207
|
+
d = json.loads(f.read_text(encoding="utf-8"))
|
|
208
|
+
return str(d.get("version") or "") or None
|
|
209
|
+
except Exception:
|
|
210
|
+
return None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Persistent toolchain host-side lifecycle (runtime-lifecycle-ux 3a).
|
|
2
|
+
|
|
3
|
+
prepare/mount/metadata for the project-scope persistent toolchain
|
|
4
|
+
(host_bind backend, per the Windows spike decision). The toolchain root is
|
|
5
|
+
the frozen data-root layout's sibling of the runtime state dir:
|
|
6
|
+
|
|
7
|
+
<data-root>/workspaces/<hash>/toolchain/{bin,npm-global,python,cargo,cache}
|
|
8
|
+
|
|
9
|
+
The environment baseline marker is seeded host-side once (never
|
|
10
|
+
overwritten — the entrypoint merges container-side facts); a mismatch
|
|
11
|
+
writes a warning file that runtime inspect surfaces as a NON-blocking
|
|
12
|
+
warning (D-RUNTIME-11: no manifest, no gate).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from aisc.domain.toolchain import (
|
|
22
|
+
ENVIRONMENT_MARKER,
|
|
23
|
+
TOOLCHAIN_SUBDIRS,
|
|
24
|
+
TOOLCHAIN_WARNING_FILE,
|
|
25
|
+
base_environment_marker,
|
|
26
|
+
toolchain_bind_argv,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
import aisc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def toolchain_dir_for(workspace_dir: Path) -> Path:
|
|
33
|
+
"""Toolchain root for a resolved data-root workspace dir
|
|
34
|
+
(``workspaces/<hash>/toolchain`` — sibling of the state subdirs)."""
|
|
35
|
+
return workspace_dir / "toolchain"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def prepare_toolchain(
|
|
39
|
+
workspace_dir: Path,
|
|
40
|
+
*,
|
|
41
|
+
image_id: str = "",
|
|
42
|
+
) -> Path:
|
|
43
|
+
"""Idempotently create the toolchain skeleton + seed the environment
|
|
44
|
+
marker (only when absent — never overwrite an existing baseline)."""
|
|
45
|
+
root = toolchain_dir_for(workspace_dir)
|
|
46
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
for sub in TOOLCHAIN_SUBDIRS:
|
|
48
|
+
(root / sub).mkdir(parents=True, exist_ok=True)
|
|
49
|
+
marker = root / ENVIRONMENT_MARKER
|
|
50
|
+
if not marker.exists():
|
|
51
|
+
version = getattr(aisc, "__version__", "") or "dev"
|
|
52
|
+
payload = base_environment_marker(
|
|
53
|
+
source_version=version,
|
|
54
|
+
image_id=image_id,
|
|
55
|
+
written_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
56
|
+
)
|
|
57
|
+
tmp = marker.with_name(marker.name + ".tmp")
|
|
58
|
+
tmp.write_text(
|
|
59
|
+
__import__("json").dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
60
|
+
)
|
|
61
|
+
tmp.replace(marker)
|
|
62
|
+
return root
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def toolchain_mount_argv(workspace_dir: Path) -> List[str]:
|
|
66
|
+
"""-v argv for the persistent toolchain mount (host_bind)."""
|
|
67
|
+
return toolchain_bind_argv(str(toolchain_dir_for(workspace_dir)).replace("\\", "/"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def toolchain_health(workspace_dir: Path) -> Dict[str, Any]:
|
|
71
|
+
"""Inspect-time toolchain summary (02 §8.4 shape, host_bind view).
|
|
72
|
+
|
|
73
|
+
``compatibility`` is ``warning`` when the entrypoint wrote the mismatch
|
|
74
|
+
file, ``unknown`` when no marker exists, else ``compatible``. Never a
|
|
75
|
+
block; no secrets (paths/ids only).
|
|
76
|
+
"""
|
|
77
|
+
root = toolchain_dir_for(workspace_dir)
|
|
78
|
+
marker = root / ENVIRONMENT_MARKER
|
|
79
|
+
warning = root / TOOLCHAIN_WARNING_FILE
|
|
80
|
+
if warning.exists():
|
|
81
|
+
compatibility = "warning"
|
|
82
|
+
elif marker.exists():
|
|
83
|
+
compatibility = "compatible"
|
|
84
|
+
else:
|
|
85
|
+
compatibility = "unknown"
|
|
86
|
+
return {
|
|
87
|
+
"mounted": root.exists(),
|
|
88
|
+
"storage": "host_bind",
|
|
89
|
+
"path": str(root),
|
|
90
|
+
"compatibility": compatibility,
|
|
91
|
+
}
|