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,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
+ }