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,1826 @@
|
|
|
1
|
+
"""Runtime application logic for Workbench Phase 0."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from aisc.domain.models import (
|
|
12
|
+
CliError,
|
|
13
|
+
ImageInspectStatus,
|
|
14
|
+
RuntimeErrorCode,
|
|
15
|
+
RuntimeExitCode,
|
|
16
|
+
RuntimeSnapshot,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Docker --memory accepts digits with an optional single byte-suffix. Keep the
|
|
21
|
+
# data layer aligned with the Rust settings sanitizer (PERF P8 review fix).
|
|
22
|
+
_MEMORY_LIMIT_RE = re.compile(r'^[0-9]+(?:[bkmg])?$')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# UUID v4 validation
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def validate_uuid_v4(runtime_id: str) -> bool:
|
|
30
|
+
"""Validate that runtime_id is a strict UUID v4.
|
|
31
|
+
|
|
32
|
+
Returns True if valid, False otherwise.
|
|
33
|
+
"""
|
|
34
|
+
uuid_v4_pattern = re.compile(
|
|
35
|
+
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
|
36
|
+
re.IGNORECASE
|
|
37
|
+
)
|
|
38
|
+
return bool(uuid_v4_pattern.match(runtime_id))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Config fingerprint
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def compute_config_fingerprint(
|
|
46
|
+
image: str,
|
|
47
|
+
network: str,
|
|
48
|
+
scope: str,
|
|
49
|
+
workspace: str,
|
|
50
|
+
proxy_config_sha256: Optional[str] = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Compute canonical config fingerprint for runtime identity.
|
|
53
|
+
|
|
54
|
+
Returns sha256:<hex> format per docs/gui-planning/03-lifecycle-contract.md.
|
|
55
|
+
|
|
56
|
+
IDEA-2 (D1): ``proxy_config_sha256`` participates only when
|
|
57
|
+
``network == "proxy"`` — direct-mode canonical JSON stays byte-identical
|
|
58
|
+
to the pre-IDEA-2 shape so existing direct containers never conflict.
|
|
59
|
+
A refreshed subscription changes the hash, so the next start sees a
|
|
60
|
+
fingerprint mismatch and the existing recreate guidance takes over.
|
|
61
|
+
"""
|
|
62
|
+
canonical_workspace = str(Path(workspace).resolve())
|
|
63
|
+
config = {
|
|
64
|
+
"image": image,
|
|
65
|
+
"network": network,
|
|
66
|
+
"scope": scope,
|
|
67
|
+
"workspace": canonical_workspace,
|
|
68
|
+
}
|
|
69
|
+
if network == "proxy":
|
|
70
|
+
config["proxy_config_sha256"] = proxy_config_sha256 or ""
|
|
71
|
+
config_str = json.dumps(config, sort_keys=True, separators=(',', ':'))
|
|
72
|
+
digest = hashlib.sha256(config_str.encode("utf-8")).hexdigest()
|
|
73
|
+
return f"sha256:{digest}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _proxy_config_sha256(network: str, proxy_config: Optional[str]) -> str:
|
|
77
|
+
"""Subscription content hash for proxy-mode fingerprints (IDEA-2 D1).
|
|
78
|
+
|
|
79
|
+
Empty string in direct mode and for proxy runs without any config file
|
|
80
|
+
(TUN without a config stays a valid mode). Read-only by design: legacy
|
|
81
|
+
adoption never happens here so preflight stays side-effect free.
|
|
82
|
+
"""
|
|
83
|
+
if network != "proxy":
|
|
84
|
+
return ""
|
|
85
|
+
path = proxy_config
|
|
86
|
+
if not path:
|
|
87
|
+
from aisc.application.network_subscription import (
|
|
88
|
+
resolve_subscription_config_path,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
path = resolve_subscription_config_path(adopt_legacy=False)
|
|
92
|
+
if not path:
|
|
93
|
+
return ""
|
|
94
|
+
try:
|
|
95
|
+
digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
96
|
+
except OSError:
|
|
97
|
+
return ""
|
|
98
|
+
return f"sha256:{digest}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Preflight
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class PreflightCheck:
|
|
107
|
+
"""Single preflight check result."""
|
|
108
|
+
id: str
|
|
109
|
+
status: str # pass, warn, fail
|
|
110
|
+
error_code: Optional[str] = None
|
|
111
|
+
detail: Optional[str] = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class PreflightResult:
|
|
116
|
+
"""Preflight command result per docs/gui-planning/05-cli-gui-contract.md §5.1."""
|
|
117
|
+
spec: Dict[str, Any]
|
|
118
|
+
checks: List[PreflightCheck]
|
|
119
|
+
can_start: bool
|
|
120
|
+
recommended_action: str # start, reuse, restart, resolve_conflict
|
|
121
|
+
matching_runtime_id: Optional[str]
|
|
122
|
+
conflicts: List[Dict[str, Any]]
|
|
123
|
+
observed_at: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def preflight_runtime(
|
|
127
|
+
runtime_id: str,
|
|
128
|
+
workspace: str,
|
|
129
|
+
image: str,
|
|
130
|
+
network: str,
|
|
131
|
+
scope: str,
|
|
132
|
+
owner: str,
|
|
133
|
+
executor: Any,
|
|
134
|
+
registry_root: Optional[Path] = None,
|
|
135
|
+
) -> PreflightResult:
|
|
136
|
+
"""Execute preflight checks for runtime start.
|
|
137
|
+
|
|
138
|
+
Read-only, zero side effects per contract §5.1.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
runtime_id: UUID v4 provided by Workbench
|
|
142
|
+
workspace: Target workspace path
|
|
143
|
+
image: Docker image name
|
|
144
|
+
network: Network mode (direct|proxy)
|
|
145
|
+
scope: Runtime scope (project|temporary)
|
|
146
|
+
owner: Owner identifier (workbench)
|
|
147
|
+
executor: Docker executor for checks
|
|
148
|
+
registry_root: Registry directory (defaults to workspace/.aisc)
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
PreflightResult with all checks and recommendation
|
|
152
|
+
"""
|
|
153
|
+
import time
|
|
154
|
+
from aisc.adapters.container_registry import list_containers
|
|
155
|
+
|
|
156
|
+
# Build spec
|
|
157
|
+
try:
|
|
158
|
+
canonical_workspace = str(Path(workspace).resolve())
|
|
159
|
+
except Exception:
|
|
160
|
+
canonical_workspace = workspace
|
|
161
|
+
|
|
162
|
+
spec = {
|
|
163
|
+
"runtime_id": runtime_id,
|
|
164
|
+
"workspace": canonical_workspace,
|
|
165
|
+
"image": image,
|
|
166
|
+
"network": network,
|
|
167
|
+
"scope": scope,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
checks: List[PreflightCheck] = []
|
|
171
|
+
|
|
172
|
+
# Check 1: docker
|
|
173
|
+
docker_available = _check_docker(executor)
|
|
174
|
+
if not docker_available:
|
|
175
|
+
checks.append(PreflightCheck(
|
|
176
|
+
id="docker",
|
|
177
|
+
status="fail",
|
|
178
|
+
error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
|
|
179
|
+
detail="Docker daemon is not running or CLI is not available"
|
|
180
|
+
))
|
|
181
|
+
else:
|
|
182
|
+
checks.append(PreflightCheck(id="docker", status="pass"))
|
|
183
|
+
|
|
184
|
+
# Check 2: workspace
|
|
185
|
+
workspace_valid = _check_workspace(workspace)
|
|
186
|
+
if not workspace_valid:
|
|
187
|
+
checks.append(PreflightCheck(
|
|
188
|
+
id="workspace",
|
|
189
|
+
status="fail",
|
|
190
|
+
error_code=RuntimeErrorCode.WORKSPACE_INVALID,
|
|
191
|
+
detail=f"Workspace does not exist or is not accessible: {workspace}"
|
|
192
|
+
))
|
|
193
|
+
else:
|
|
194
|
+
checks.append(PreflightCheck(id="workspace", status="pass"))
|
|
195
|
+
|
|
196
|
+
# Check 3: image
|
|
197
|
+
image_error = None
|
|
198
|
+
if docker_available:
|
|
199
|
+
image_error = _check_image(image, executor)
|
|
200
|
+
else:
|
|
201
|
+
image_error = RuntimeErrorCode.DOCKER_UNAVAILABLE
|
|
202
|
+
|
|
203
|
+
if image_error:
|
|
204
|
+
checks.append(PreflightCheck(
|
|
205
|
+
id="image",
|
|
206
|
+
status="fail",
|
|
207
|
+
error_code=image_error,
|
|
208
|
+
detail=f"Image not found: {image}" if image_error == RuntimeErrorCode.IMAGE_NOT_FOUND
|
|
209
|
+
else "Cannot check image availability (Docker unreachable)"
|
|
210
|
+
))
|
|
211
|
+
else:
|
|
212
|
+
checks.append(PreflightCheck(id="image", status="pass"))
|
|
213
|
+
|
|
214
|
+
# Check 4: network
|
|
215
|
+
network_valid = network in ("direct", "proxy")
|
|
216
|
+
if not network_valid:
|
|
217
|
+
checks.append(PreflightCheck(
|
|
218
|
+
id="network",
|
|
219
|
+
status="fail",
|
|
220
|
+
error_code=RuntimeErrorCode.NETWORK_INVALID,
|
|
221
|
+
detail=f"Invalid network mode: {network}"
|
|
222
|
+
))
|
|
223
|
+
else:
|
|
224
|
+
checks.append(PreflightCheck(id="network", status="pass"))
|
|
225
|
+
|
|
226
|
+
# Check 5: runtime_conflict (Stage 7: data-root state dir, DATA-01)
|
|
227
|
+
if registry_root is None and workspace_valid:
|
|
228
|
+
from aisc.application.data_root import workspace_state_dir
|
|
229
|
+
|
|
230
|
+
registry_root = workspace_state_dir(Path(canonical_workspace))
|
|
231
|
+
|
|
232
|
+
conflict_check, matching_runtime_id, conflicts, matching_state = _check_runtime_conflict(
|
|
233
|
+
runtime_id=runtime_id,
|
|
234
|
+
workspace=canonical_workspace,
|
|
235
|
+
image=image,
|
|
236
|
+
network=network,
|
|
237
|
+
scope=scope,
|
|
238
|
+
registry_root=registry_root,
|
|
239
|
+
docker_available=docker_available,
|
|
240
|
+
executor=executor,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
checks.append(conflict_check)
|
|
244
|
+
|
|
245
|
+
# Compute can_start and recommended_action. "resolve_conflict" is reserved
|
|
246
|
+
# for actual runtime conflicts (runtime_conflict check failed). Other
|
|
247
|
+
# failed gates (docker down, image missing, bad workspace) keep
|
|
248
|
+
# action="start" - the UI's per-gate messages/buttons drive recovery, and
|
|
249
|
+
# a fresh install with a missing image must not be mislabeled as a
|
|
250
|
+
# runtime conflict (S4.1.b regression).
|
|
251
|
+
all_pass = all(c.status == "pass" for c in checks)
|
|
252
|
+
can_start = all_pass and matching_runtime_id is None
|
|
253
|
+
|
|
254
|
+
if conflict_check.status == "fail":
|
|
255
|
+
recommended_action = "resolve_conflict"
|
|
256
|
+
elif matching_runtime_id:
|
|
257
|
+
# Config fingerprint matches existing runtime
|
|
258
|
+
# Recommend reuse if running, restart if stopped
|
|
259
|
+
if matching_state == "running":
|
|
260
|
+
recommended_action = "reuse"
|
|
261
|
+
elif matching_state == "stopped":
|
|
262
|
+
recommended_action = "restart"
|
|
263
|
+
else:
|
|
264
|
+
# Unknown state, default to reuse
|
|
265
|
+
recommended_action = "reuse"
|
|
266
|
+
else:
|
|
267
|
+
recommended_action = "start"
|
|
268
|
+
|
|
269
|
+
observed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
270
|
+
|
|
271
|
+
return PreflightResult(
|
|
272
|
+
spec=spec,
|
|
273
|
+
checks=checks,
|
|
274
|
+
can_start=can_start,
|
|
275
|
+
recommended_action=recommended_action,
|
|
276
|
+
matching_runtime_id=matching_runtime_id,
|
|
277
|
+
conflicts=conflicts,
|
|
278
|
+
observed_at=observed_at,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _check_docker(executor: Any) -> bool:
|
|
283
|
+
"""Check if Docker is available."""
|
|
284
|
+
try:
|
|
285
|
+
result = executor.preflight()
|
|
286
|
+
return result.available
|
|
287
|
+
except Exception:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _check_workspace(workspace: str) -> bool:
|
|
292
|
+
"""Check if workspace exists and is a directory."""
|
|
293
|
+
try:
|
|
294
|
+
p = Path(workspace)
|
|
295
|
+
return p.exists() and p.is_dir()
|
|
296
|
+
except Exception:
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _check_image(image: str, executor: Any) -> Optional[str]:
|
|
301
|
+
"""Check if Docker image exists locally.
|
|
302
|
+
|
|
303
|
+
Returns None if image exists, error code string otherwise.
|
|
304
|
+
Distinguishes "image not found" from "cannot observe".
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
result = executor.inspect_image(image)
|
|
308
|
+
from aisc.domain.models import ImageInspectStatus
|
|
309
|
+
if result.status == ImageInspectStatus.EXISTS:
|
|
310
|
+
return None
|
|
311
|
+
if result.status == ImageInspectStatus.MISSING:
|
|
312
|
+
return RuntimeErrorCode.IMAGE_NOT_FOUND
|
|
313
|
+
# DOCKER_UNAVAILABLE, PERMISSION_DENIED, TIMEOUT, ERROR
|
|
314
|
+
return RuntimeErrorCode.DOCKER_UNAVAILABLE
|
|
315
|
+
except Exception:
|
|
316
|
+
return RuntimeErrorCode.DOCKER_UNAVAILABLE
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _resolve_image_id(image: str, executor: Any) -> Optional[str]:
|
|
320
|
+
"""Content-addressed image ID (``docker image inspect .Id``) for the
|
|
321
|
+
image-sync conflict check (容器随镜像同步更新, KI-4 挂账).
|
|
322
|
+
|
|
323
|
+
Returns None whenever the ID cannot be observed — a transient probe
|
|
324
|
+
failure must never block reuse (unknown ≠ changed). Legacy registry
|
|
325
|
+
records without an ``image_id`` are likewise treated as unknown.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
result = executor.inspect_image(image)
|
|
329
|
+
from aisc.domain.models import ImageInspectStatus
|
|
330
|
+
if result.status != ImageInspectStatus.EXISTS:
|
|
331
|
+
return None
|
|
332
|
+
return (getattr(result, "image_id", "") or "").strip() or None
|
|
333
|
+
except Exception:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _query_docker_labels(
|
|
338
|
+
runtime_id: str,
|
|
339
|
+
executor: Any,
|
|
340
|
+
) -> List[Dict[str, str]]:
|
|
341
|
+
"""Query Docker for containers with matching io.aisc.runtime-id label.
|
|
342
|
+
|
|
343
|
+
Returns list of {container_name, container_id, state, labels...} dicts.
|
|
344
|
+
Empty list if Docker unavailable or no matches.
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
filter_label = f"label=io.aisc.runtime-id={runtime_id}"
|
|
348
|
+
result = executor.run_captured(
|
|
349
|
+
["ps", "-a", "--filter", filter_label, "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}"],
|
|
350
|
+
timeout=10.0,
|
|
351
|
+
)
|
|
352
|
+
except AttributeError:
|
|
353
|
+
# If executor doesn't have run_captured (e.g., protocol mock), skip
|
|
354
|
+
return []
|
|
355
|
+
except Exception:
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
if result.exit_code != 0:
|
|
359
|
+
return []
|
|
360
|
+
|
|
361
|
+
containers = []
|
|
362
|
+
for line in result.stdout.strip().split("\n"):
|
|
363
|
+
if not line.strip():
|
|
364
|
+
continue
|
|
365
|
+
parts = line.split("\t")
|
|
366
|
+
if len(parts) >= 3:
|
|
367
|
+
containers.append({
|
|
368
|
+
"container_id": parts[0].strip(),
|
|
369
|
+
"container_name": parts[1].strip(),
|
|
370
|
+
"status": parts[2].strip(),
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
return containers
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _check_runtime_conflict(
|
|
377
|
+
runtime_id: str,
|
|
378
|
+
workspace: str,
|
|
379
|
+
image: str,
|
|
380
|
+
network: str,
|
|
381
|
+
scope: str,
|
|
382
|
+
registry_root: Optional[Path],
|
|
383
|
+
docker_available: bool,
|
|
384
|
+
executor: Any,
|
|
385
|
+
proxy_config: Optional[str] = None,
|
|
386
|
+
) -> tuple[PreflightCheck, Optional[str], List[Dict[str, Any]], Optional[str]]:
|
|
387
|
+
"""Check for runtime conflicts.
|
|
388
|
+
|
|
389
|
+
Returns (check, matching_runtime_id, conflicts, matching_state).
|
|
390
|
+
matching_state is "running", "stopped", or None.
|
|
391
|
+
Fail-closed: if registry cannot be read, returns conflict fail.
|
|
392
|
+
Also checks Docker labels to reconcile registry with actual container state.
|
|
393
|
+
|
|
394
|
+
``proxy_config`` (IDEA-2 D1) feeds the fingerprint's subscription hash;
|
|
395
|
+
``None`` auto-resolves read-only so preflight and start agree on the
|
|
396
|
+
file the start would mount.
|
|
397
|
+
"""
|
|
398
|
+
from aisc.adapters.container_registry import list_containers_readonly
|
|
399
|
+
|
|
400
|
+
if not docker_available or registry_root is None:
|
|
401
|
+
# Cannot verify conflicts without Docker or registry
|
|
402
|
+
return (
|
|
403
|
+
PreflightCheck(id="runtime_conflict", status="pass"),
|
|
404
|
+
None,
|
|
405
|
+
[],
|
|
406
|
+
None
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Read registry without lock (read-only snapshot)
|
|
410
|
+
# Returns empty dict if registry doesn't exist (fresh workspace)
|
|
411
|
+
# Raises exception if registry exists but is corrupted
|
|
412
|
+
try:
|
|
413
|
+
registry_containers = list_containers_readonly(registry_root)
|
|
414
|
+
except Exception as e:
|
|
415
|
+
# Fail-closed: registry exists but cannot be read
|
|
416
|
+
return (
|
|
417
|
+
PreflightCheck(
|
|
418
|
+
id="runtime_conflict",
|
|
419
|
+
status="fail",
|
|
420
|
+
error_code=RuntimeErrorCode.RUNTIME_CONFLICT,
|
|
421
|
+
detail=f"Cannot read registry: {e}"
|
|
422
|
+
),
|
|
423
|
+
None,
|
|
424
|
+
[],
|
|
425
|
+
None
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Compute config fingerprint for this request
|
|
429
|
+
fingerprint = compute_config_fingerprint(
|
|
430
|
+
image, network, scope, workspace,
|
|
431
|
+
proxy_config_sha256=_proxy_config_sha256(network, proxy_config),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# 容器随镜像同步更新 (KI-4 挂账): the current image's content-addressed
|
|
435
|
+
# ID, compared against the one recorded at create time inside the reuse
|
|
436
|
+
# branch below. None (probe failure) disables the comparison — reuse is
|
|
437
|
+
# never blocked on a transient Docker hiccup.
|
|
438
|
+
current_image_id = _resolve_image_id(image, executor)
|
|
439
|
+
|
|
440
|
+
# Query Docker for containers with matching runtime-id label
|
|
441
|
+
docker_containers = _query_docker_labels(runtime_id, executor)
|
|
442
|
+
registry_names = set(registry_containers.keys())
|
|
443
|
+
|
|
444
|
+
# Track which Docker container names are NOT in registry
|
|
445
|
+
docker_only_names = set()
|
|
446
|
+
for dc in docker_containers:
|
|
447
|
+
if dc["container_name"] not in registry_names:
|
|
448
|
+
docker_only_names.add(dc["container_name"])
|
|
449
|
+
|
|
450
|
+
matching_runtime_id = None
|
|
451
|
+
matching_state = None # "running" or "stopped"
|
|
452
|
+
conflicts: List[Dict[str, Any]] = []
|
|
453
|
+
|
|
454
|
+
# Process registry entries
|
|
455
|
+
for container_name, meta in registry_containers.items():
|
|
456
|
+
meta_runtime_id = meta.get("runtime_id", "")
|
|
457
|
+
meta_workspace = meta.get("workspace", "")
|
|
458
|
+
meta_scope = meta.get("scope", "")
|
|
459
|
+
meta_fingerprint = meta.get("config_fingerprint", "")
|
|
460
|
+
meta_owner = meta.get("owner", "")
|
|
461
|
+
|
|
462
|
+
# Check actual Docker container state
|
|
463
|
+
docker_state = _get_container_state(container_name, executor)
|
|
464
|
+
|
|
465
|
+
# Legacy record detection: if scope or owner cannot be confirmed,
|
|
466
|
+
# report as conflict per contract §5.1 line 140
|
|
467
|
+
if not meta_scope or not meta_owner or not meta_runtime_id:
|
|
468
|
+
conflicts.append({
|
|
469
|
+
"runtime_id": meta_runtime_id or "(missing)",
|
|
470
|
+
"container_name": container_name,
|
|
471
|
+
"reason": (
|
|
472
|
+
"Legacy container record: missing "
|
|
473
|
+
+ ", ".join(
|
|
474
|
+
[f for f, v in [
|
|
475
|
+
("runtime_id", meta_runtime_id),
|
|
476
|
+
("scope", meta_scope),
|
|
477
|
+
("owner", meta_owner),
|
|
478
|
+
] if not v]
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
})
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
# Same runtime ID, same fingerprint -> can reuse or restart
|
|
485
|
+
if meta_runtime_id == runtime_id and meta_fingerprint == fingerprint:
|
|
486
|
+
if docker_state == "not_found":
|
|
487
|
+
# Stale registry record: the container was deleted (manually or
|
|
488
|
+
# otherwise) while the registry entry survived. Reusing it would
|
|
489
|
+
# dead-end on a missing container (observed 2026-08-10: preflight
|
|
490
|
+
# reported reuse, the app opened the session, docker exec failed
|
|
491
|
+
# with RUNTIME_NOT_FOUND, and re-preflight looped). Surface it as
|
|
492
|
+
# a conflict so the UI lists the stale record and the user can
|
|
493
|
+
# remove it, then start fresh - never as a reusable runtime.
|
|
494
|
+
conflicts.append({
|
|
495
|
+
"runtime_id": meta_runtime_id,
|
|
496
|
+
"container_name": container_name,
|
|
497
|
+
"reason": "Registered runtime container no longer exists (stale record)"
|
|
498
|
+
})
|
|
499
|
+
continue
|
|
500
|
+
# 容器随镜像同步更新: the fingerprint matches, but a rebuilt
|
|
501
|
+
# image under the same tag would keep this container running
|
|
502
|
+
# stale layers forever. A recorded image_id that differs from
|
|
503
|
+
# the current one means the image changed — surface a conflict
|
|
504
|
+
# so the existing recreate guidance takes over. Legacy records
|
|
505
|
+
# without an image_id stay reusable (unknown ≠ changed) and are
|
|
506
|
+
# healed by the next successful start.
|
|
507
|
+
meta_image_id = str(meta.get("image_id") or "").strip()
|
|
508
|
+
if meta_image_id and current_image_id and meta_image_id != current_image_id:
|
|
509
|
+
conflicts.append({
|
|
510
|
+
"runtime_id": meta_runtime_id,
|
|
511
|
+
"container_name": container_name,
|
|
512
|
+
"reason": (
|
|
513
|
+
"Runtime image updated (image ID changed) — "
|
|
514
|
+
"remove and start again to recreate on the new image"
|
|
515
|
+
),
|
|
516
|
+
})
|
|
517
|
+
continue
|
|
518
|
+
matching_runtime_id = meta_runtime_id
|
|
519
|
+
matching_state = docker_state
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
# Same runtime ID, different fingerprint -> conflict
|
|
523
|
+
if meta_runtime_id == runtime_id and meta_fingerprint != fingerprint:
|
|
524
|
+
conflicts.append({
|
|
525
|
+
"runtime_id": meta_runtime_id,
|
|
526
|
+
"container_name": container_name,
|
|
527
|
+
"reason": "Runtime ID already in use with different config"
|
|
528
|
+
})
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
# Project scope: same workspace -> conflict
|
|
532
|
+
if scope == "project" and meta_scope == "project":
|
|
533
|
+
try:
|
|
534
|
+
canonical_meta_workspace = str(Path(meta_workspace).resolve())
|
|
535
|
+
canonical_request_workspace = str(Path(workspace).resolve())
|
|
536
|
+
if canonical_meta_workspace == canonical_request_workspace:
|
|
537
|
+
conflicts.append({
|
|
538
|
+
"runtime_id": meta_runtime_id,
|
|
539
|
+
"container_name": container_name,
|
|
540
|
+
"reason": "Project runtime already exists for this workspace"
|
|
541
|
+
})
|
|
542
|
+
except Exception:
|
|
543
|
+
pass
|
|
544
|
+
|
|
545
|
+
# Report Docker-only containers as conflicts (label reconciliation)
|
|
546
|
+
for dc in docker_containers:
|
|
547
|
+
name = dc["container_name"]
|
|
548
|
+
if name in docker_only_names and name not in registry_names:
|
|
549
|
+
conflicts.append({
|
|
550
|
+
"runtime_id": runtime_id,
|
|
551
|
+
"container_name": name,
|
|
552
|
+
"reason": "Container exists in Docker but not in registry (labels only)"
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
if conflicts:
|
|
556
|
+
return (
|
|
557
|
+
PreflightCheck(
|
|
558
|
+
id="runtime_conflict",
|
|
559
|
+
status="fail",
|
|
560
|
+
error_code=RuntimeErrorCode.RUNTIME_CONFLICT,
|
|
561
|
+
detail=f"Found {len(conflicts)} conflicting runtime(s)"
|
|
562
|
+
),
|
|
563
|
+
matching_runtime_id,
|
|
564
|
+
conflicts,
|
|
565
|
+
matching_state
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
return (
|
|
569
|
+
PreflightCheck(id="runtime_conflict", status="pass"),
|
|
570
|
+
matching_runtime_id,
|
|
571
|
+
[],
|
|
572
|
+
matching_state
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _get_container_state(container_name: str, executor: Any) -> Optional[str]:
|
|
577
|
+
"""Get container state from Docker.
|
|
578
|
+
|
|
579
|
+
Returns "running", "stopped", "not_found" when the container is absent, or
|
|
580
|
+
None if the inspect failed for another (transient) reason.
|
|
581
|
+
"""
|
|
582
|
+
try:
|
|
583
|
+
result = executor.inspect_container(container_name)
|
|
584
|
+
if result.exit_code != 0:
|
|
585
|
+
# docker inspect exits 1 with "No such object/container" for a
|
|
586
|
+
# missing container; any other nonzero exit is an inspect error.
|
|
587
|
+
stderr = (result.stderr or "").lower()
|
|
588
|
+
if "no such object" in stderr or "no such container" in stderr:
|
|
589
|
+
return "not_found"
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
# Parse JSON output to get State.Running
|
|
593
|
+
import json
|
|
594
|
+
data = json.loads(result.stdout)
|
|
595
|
+
if isinstance(data, list) and len(data) > 0:
|
|
596
|
+
state = data[0].get("State", {})
|
|
597
|
+
running = state.get("Running", False)
|
|
598
|
+
return "running" if running else "stopped"
|
|
599
|
+
return None
|
|
600
|
+
except Exception:
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ---------------------------------------------------------------------------
|
|
605
|
+
# Runtime lifecycle: start / list / inspect / stop / restart / remove
|
|
606
|
+
# (docs/gui-planning/05-cli-gui-contract.md §5.2-5.5)
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
_RUNTIME_CONTEXT_PATH = "/run/aisc/runtime-context.json"
|
|
610
|
+
_RUNTIME_CONTEXT_SCHEMA = "aisc.runtime-context/v1"
|
|
611
|
+
_READY_POLL_INTERVAL = 0.5
|
|
612
|
+
_READY_DEFAULT_TIMEOUT = 60.0
|
|
613
|
+
# S8d (VM retest feedback #4): a workspace's FIRST start pages the image
|
|
614
|
+
# layers off disk and runs the entrypoint's first-time init — on weak
|
|
615
|
+
# (nested-virtualization) hardware that takes multiples of the steady-state
|
|
616
|
+
# budget, so "first use always times out at 60s". Cold starts get triple
|
|
617
|
+
# budget; warm starts keep the fast-fail.
|
|
618
|
+
_READY_COLD_TIMEOUT = 180.0
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def container_name_for(runtime_id: str) -> str:
|
|
622
|
+
"""Deterministic container name: ``aisc-wb-<first 8 hex of runtime_id>``.
|
|
623
|
+
|
|
624
|
+
Matches contract §5.2 example ``aisc-wb-0e7b7e3b``. Deterministic name
|
|
625
|
+
enables idempotent retry and targeted ``docker rm``.
|
|
626
|
+
|
|
627
|
+
Note: the 8-hex prefix carries 32 bits of entropy. Docker container names
|
|
628
|
+
are global, and the conflict check is keyed on ``runtime_id`` (128 bits),
|
|
629
|
+
not on the name. At ~65k runtimes per Docker daemon the birthday bound
|
|
630
|
+
makes a name collision plausible; a colliding ``docker run --name`` then
|
|
631
|
+
fails with RUNTIME_OPERATION_FAILED rather than a conflict. Acceptable for
|
|
632
|
+
a single workstation; revisit if multi-tenant.
|
|
633
|
+
"""
|
|
634
|
+
return f"aisc-wb-{runtime_id.split('-', 1)[0]}"
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def workspace_key_for(workspace: str) -> str:
|
|
638
|
+
"""Return ``sha256`` hex of the canonical workspace path.
|
|
639
|
+
|
|
640
|
+
Used as ``io.aisc.workspace-key`` label and the workspace-lock filename.
|
|
641
|
+
Never the raw host path.
|
|
642
|
+
"""
|
|
643
|
+
canonical = str(Path(workspace).resolve())
|
|
644
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _docker_status_to_state(status: str) -> str:
|
|
648
|
+
"""Map a ``docker ps`` status string to a runtime state."""
|
|
649
|
+
if not status:
|
|
650
|
+
return "unknown"
|
|
651
|
+
if status.startswith("Up"):
|
|
652
|
+
return "running"
|
|
653
|
+
return "stopped"
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _find_docker_container_by_runtime_id(
|
|
657
|
+
runtime_id: str, executor: Any
|
|
658
|
+
) -> Optional[Dict[str, str]]:
|
|
659
|
+
"""Find a Docker container carrying ``io.aisc.runtime-id=<runtime_id>``.
|
|
660
|
+
|
|
661
|
+
Returns ``{container_name, container_id, state}`` or ``None``. Uses the
|
|
662
|
+
same label query as preflight reconciliation.
|
|
663
|
+
"""
|
|
664
|
+
containers = _query_docker_labels(runtime_id, executor)
|
|
665
|
+
if not containers:
|
|
666
|
+
return None
|
|
667
|
+
c = containers[0]
|
|
668
|
+
return {
|
|
669
|
+
"container_name": c.get("container_name", ""),
|
|
670
|
+
"container_id": c.get("container_id", ""),
|
|
671
|
+
"state": _docker_status_to_state(c.get("status", "")),
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def resolve_running_container(
|
|
676
|
+
runtime_id: str,
|
|
677
|
+
executor: Any,
|
|
678
|
+
registry_root: Any,
|
|
679
|
+
) -> str:
|
|
680
|
+
"""Resolve *runtime_id* to a running container's name.
|
|
681
|
+
|
|
682
|
+
Used by data-plane commands (session, provider) that need to ``docker exec``
|
|
683
|
+
into the runtime. Raises ``CliError`` with a stable code if Docker is
|
|
684
|
+
unavailable, the runtime is not found, or it is not running.
|
|
685
|
+
"""
|
|
686
|
+
from aisc.adapters.container_registry import find_by_runtime_id
|
|
687
|
+
|
|
688
|
+
if not _check_docker(executor):
|
|
689
|
+
raise CliError(
|
|
690
|
+
message="Docker daemon is not running or CLI is not available",
|
|
691
|
+
exit_code=RuntimeExitCode.DOCKER_UNAVAILABLE,
|
|
692
|
+
error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
container_name = ""
|
|
696
|
+
found = find_by_runtime_id(registry_root, runtime_id)
|
|
697
|
+
if found is not None:
|
|
698
|
+
container_name = found[0]
|
|
699
|
+
else:
|
|
700
|
+
dc = _find_docker_container_by_runtime_id(runtime_id, executor)
|
|
701
|
+
if dc is not None:
|
|
702
|
+
container_name = dc["container_name"]
|
|
703
|
+
|
|
704
|
+
if not container_name:
|
|
705
|
+
raise CliError(
|
|
706
|
+
message=f"Runtime not found: {runtime_id}",
|
|
707
|
+
exit_code=RuntimeExitCode.GENERAL_ERROR,
|
|
708
|
+
error_code=RuntimeErrorCode.RUNTIME_NOT_FOUND,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
state = _get_container_state(container_name, executor)
|
|
712
|
+
if state != "running":
|
|
713
|
+
raise CliError(
|
|
714
|
+
message=f"Runtime is not running (state: {state or 'not_found'}). "
|
|
715
|
+
f"Start it first: aisc runtime start --runtime-id {runtime_id}",
|
|
716
|
+
exit_code=RuntimeExitCode.RUNTIME_NOT_RUNNING,
|
|
717
|
+
error_code=RuntimeErrorCode.RUNTIME_NOT_RUNNING,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
return container_name
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _list_docker_runtime_containers(
|
|
724
|
+
executor: Any, workspace_key: Optional[str] = None
|
|
725
|
+
) -> List[Dict[str, str]]:
|
|
726
|
+
"""List Docker containers labeled ``io.aisc.managed=true kind=runtime``.
|
|
727
|
+
|
|
728
|
+
Optionally filtered by ``io.aisc.workspace-key``. Returns dicts with
|
|
729
|
+
``container_name``, ``container_id``, ``state``, ``runtime_id``,
|
|
730
|
+
``workspace_key``, ``owner``, ``image``.
|
|
731
|
+
"""
|
|
732
|
+
fmt = (
|
|
733
|
+
"{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t"
|
|
734
|
+
"{{.Label \"io.aisc.runtime-id\"}}\t"
|
|
735
|
+
"{{.Label \"io.aisc.workspace-key\"}}\t"
|
|
736
|
+
"{{.Label \"io.aisc.owner\"}}"
|
|
737
|
+
)
|
|
738
|
+
argv = [
|
|
739
|
+
"ps", "-a",
|
|
740
|
+
"--filter", "label=io.aisc.managed=true",
|
|
741
|
+
"--filter", "label=io.aisc.kind=runtime",
|
|
742
|
+
"--format", fmt,
|
|
743
|
+
]
|
|
744
|
+
try:
|
|
745
|
+
result = executor.run_captured(argv, timeout=10.0)
|
|
746
|
+
except AttributeError:
|
|
747
|
+
return []
|
|
748
|
+
except Exception:
|
|
749
|
+
# Transient docker ps failure (e.g. a momentary timeout): Docker-only
|
|
750
|
+
# containers become invisible this round and any registry entry for
|
|
751
|
+
# them is reported as state="not_found". A false negative, not an
|
|
752
|
+
# error - _require_docker already passed; the next refresh recovers.
|
|
753
|
+
return []
|
|
754
|
+
if result.exit_code != 0:
|
|
755
|
+
return []
|
|
756
|
+
|
|
757
|
+
out: List[Dict[str, str]] = []
|
|
758
|
+
for line in result.stdout.strip().split("\n"):
|
|
759
|
+
if not line.strip():
|
|
760
|
+
continue
|
|
761
|
+
parts = line.split("\t")
|
|
762
|
+
if len(parts) < 7:
|
|
763
|
+
continue
|
|
764
|
+
cid, name, image, status, rid, wskey, owner = parts[:7]
|
|
765
|
+
if workspace_key is not None and wskey != workspace_key:
|
|
766
|
+
continue
|
|
767
|
+
out.append({
|
|
768
|
+
"container_id": cid.strip(),
|
|
769
|
+
"container_name": name.strip(),
|
|
770
|
+
"image": image.strip(),
|
|
771
|
+
"state": _docker_status_to_state(status.strip()),
|
|
772
|
+
"runtime_id": rid.strip(),
|
|
773
|
+
"workspace_key": wskey.strip(),
|
|
774
|
+
"owner": owner.strip(),
|
|
775
|
+
})
|
|
776
|
+
return out
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _registry_has_history(reg_root: Path) -> bool:
|
|
780
|
+
"""S8d: True when this workspace's registry already records a runtime —
|
|
781
|
+
the proxy for "this host started this workspace before" (warm caches).
|
|
782
|
+
A missing/empty registry means a cold first start. An unreadable
|
|
783
|
+
(corrupt) registry keeps the steady-state timeout: the default must
|
|
784
|
+
never depend on a broken state file."""
|
|
785
|
+
from aisc.adapters.container_registry import list_containers_readonly
|
|
786
|
+
|
|
787
|
+
try:
|
|
788
|
+
return bool(list_containers_readonly(reg_root))
|
|
789
|
+
except Exception:
|
|
790
|
+
return True
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _wait_ready(
|
|
794
|
+
executor: Any,
|
|
795
|
+
container_name: str,
|
|
796
|
+
runtime_id: str,
|
|
797
|
+
timeout: float = _READY_DEFAULT_TIMEOUT,
|
|
798
|
+
) -> Optional[Dict[str, Any]]:
|
|
799
|
+
"""Poll the container's runtime-context.json until ready or timeout.
|
|
800
|
+
|
|
801
|
+
Returns the validated context dict, or ``None`` on timeout/validation
|
|
802
|
+
failure. Ready means: file exists, parses, ``schema_version`` present and
|
|
803
|
+
``runtime_id`` matches. Transient ``docker exec`` errors (e.g. the
|
|
804
|
+
container briefly in a "restarting" state) keep polling until the deadline
|
|
805
|
+
rather than aborting cleanup of a healthy container.
|
|
806
|
+
"""
|
|
807
|
+
deadline = time.monotonic() + timeout
|
|
808
|
+
while time.monotonic() < deadline:
|
|
809
|
+
try:
|
|
810
|
+
result = executor.run_captured(
|
|
811
|
+
["exec", container_name, "cat", _RUNTIME_CONTEXT_PATH],
|
|
812
|
+
timeout=5.0,
|
|
813
|
+
)
|
|
814
|
+
except Exception:
|
|
815
|
+
# Transient exec error; retry until deadline.
|
|
816
|
+
time.sleep(_READY_POLL_INTERVAL)
|
|
817
|
+
continue
|
|
818
|
+
if result.exit_code == 0 and result.stdout.strip():
|
|
819
|
+
try:
|
|
820
|
+
ctx = json.loads(result.stdout)
|
|
821
|
+
except (ValueError, TypeError):
|
|
822
|
+
ctx = None
|
|
823
|
+
if isinstance(ctx, dict) and ctx.get("runtime_id") == runtime_id \
|
|
824
|
+
and ctx.get("schema_version"):
|
|
825
|
+
return ctx
|
|
826
|
+
time.sleep(_READY_POLL_INTERVAL)
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _resolve_registry_root(
|
|
831
|
+
workspace: str, registry_root: Optional[Path]
|
|
832
|
+
) -> Path:
|
|
833
|
+
"""Return the registry state dir for *workspace*.
|
|
834
|
+
|
|
835
|
+
Stage 7: default is the data-root ``workspaces/<hash>/runtime`` dir
|
|
836
|
+
(DATA-01) — never a path inside the workspace.
|
|
837
|
+
"""
|
|
838
|
+
if registry_root is not None:
|
|
839
|
+
return registry_root
|
|
840
|
+
from aisc.application.data_root import workspace_state_dir
|
|
841
|
+
|
|
842
|
+
return workspace_state_dir(Path(workspace).resolve())
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def iso_now() -> str:
|
|
846
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _require_docker(executor: Any) -> None:
|
|
850
|
+
"""Raise CliError(3) if Docker is unavailable."""
|
|
851
|
+
if not _check_docker(executor):
|
|
852
|
+
raise CliError(
|
|
853
|
+
message="Docker daemon is not running or CLI is not available",
|
|
854
|
+
exit_code=RuntimeExitCode.DOCKER_UNAVAILABLE,
|
|
855
|
+
error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _require_image(image: str, executor: Any) -> None:
|
|
860
|
+
"""Raise CliError if *image* is not locally available."""
|
|
861
|
+
err = _check_image(image, executor)
|
|
862
|
+
if err is None:
|
|
863
|
+
return
|
|
864
|
+
if err == RuntimeErrorCode.IMAGE_NOT_FOUND:
|
|
865
|
+
raise CliError(
|
|
866
|
+
message=f"Image '{image}' not found. Build it first: aisc build --tag {image}",
|
|
867
|
+
exit_code=RuntimeExitCode.IMAGE_NOT_FOUND,
|
|
868
|
+
error_code=RuntimeErrorCode.IMAGE_NOT_FOUND,
|
|
869
|
+
)
|
|
870
|
+
# DOCKER_UNAVAILABLE / others
|
|
871
|
+
raise CliError(
|
|
872
|
+
message=f"Cannot verify image '{image}': Docker unreachable",
|
|
873
|
+
exit_code=RuntimeExitCode.DOCKER_UNAVAILABLE,
|
|
874
|
+
error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@dataclass
|
|
879
|
+
class RuntimeStartResult:
|
|
880
|
+
"""Result of ``aisc runtime start`` per contract §5.2."""
|
|
881
|
+
runtime_id: str
|
|
882
|
+
container_name: str
|
|
883
|
+
container_id: str
|
|
884
|
+
state: str
|
|
885
|
+
ready: bool
|
|
886
|
+
reused: bool
|
|
887
|
+
config: Dict[str, Any]
|
|
888
|
+
config_fingerprint: str
|
|
889
|
+
created_at: str
|
|
890
|
+
|
|
891
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
892
|
+
return {
|
|
893
|
+
"runtime_id": self.runtime_id,
|
|
894
|
+
"container_name": self.container_name,
|
|
895
|
+
"container_id": self.container_id,
|
|
896
|
+
"state": self.state,
|
|
897
|
+
"ready": self.ready,
|
|
898
|
+
"reused": self.reused,
|
|
899
|
+
"config": self.config,
|
|
900
|
+
"config_fingerprint": self.config_fingerprint,
|
|
901
|
+
"created_at": self.created_at,
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _build_start_payload(
|
|
906
|
+
runtime_id: str,
|
|
907
|
+
workspace: str,
|
|
908
|
+
image: str,
|
|
909
|
+
network: str,
|
|
910
|
+
scope: str,
|
|
911
|
+
container_name: str,
|
|
912
|
+
container_id: str,
|
|
913
|
+
state: str,
|
|
914
|
+
ready: bool,
|
|
915
|
+
reused: bool,
|
|
916
|
+
fingerprint: str,
|
|
917
|
+
) -> RuntimeStartResult:
|
|
918
|
+
return RuntimeStartResult(
|
|
919
|
+
runtime_id=runtime_id,
|
|
920
|
+
container_name=container_name,
|
|
921
|
+
container_id=container_id,
|
|
922
|
+
state=state,
|
|
923
|
+
ready=ready,
|
|
924
|
+
reused=reused,
|
|
925
|
+
config={
|
|
926
|
+
"workspace": workspace,
|
|
927
|
+
"image": image,
|
|
928
|
+
"network": network,
|
|
929
|
+
"scope": scope,
|
|
930
|
+
},
|
|
931
|
+
config_fingerprint=fingerprint,
|
|
932
|
+
created_at=iso_now(),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def start_runtime(
|
|
937
|
+
runtime_id: str,
|
|
938
|
+
workspace: str,
|
|
939
|
+
image: str,
|
|
940
|
+
network: str,
|
|
941
|
+
scope: str,
|
|
942
|
+
owner: str,
|
|
943
|
+
executor: Any,
|
|
944
|
+
registry_root: Optional[Path] = None,
|
|
945
|
+
ready_timeout: float = _READY_DEFAULT_TIMEOUT,
|
|
946
|
+
proxy_config: Optional[str] = None,
|
|
947
|
+
host_mcp_url: Optional[str] = None,
|
|
948
|
+
max_memory: Optional[str] = None,
|
|
949
|
+
max_cpus: Optional[float] = None,
|
|
950
|
+
) -> RuntimeStartResult:
|
|
951
|
+
"""Start a Workbench runtime per contract §5.2.
|
|
952
|
+
|
|
953
|
+
Acquires the workspace lock, re-validates conflicts (idempotent reuse /
|
|
954
|
+
conflict), creates a detached idle container, waits for readiness, then
|
|
955
|
+
commits the registry entry. On registry-commit failure the new container
|
|
956
|
+
is removed. Returns the §5.2 payload.
|
|
957
|
+
"""
|
|
958
|
+
from aisc.adapters.container_registry import (
|
|
959
|
+
list_containers_readonly,
|
|
960
|
+
register,
|
|
961
|
+
workspace_lock,
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
# --- validate inputs ---
|
|
965
|
+
if not validate_uuid_v4(runtime_id):
|
|
966
|
+
raise CliError(
|
|
967
|
+
message=f"Invalid runtime ID (must be UUID v4): {runtime_id}",
|
|
968
|
+
exit_code=RuntimeExitCode.INVALID_RUNTIME_ID,
|
|
969
|
+
error_code=RuntimeErrorCode.INVALID_RUNTIME_ID,
|
|
970
|
+
)
|
|
971
|
+
if scope not in ("project", "temporary"):
|
|
972
|
+
raise CliError(
|
|
973
|
+
message=f"Invalid scope (must be project|temporary): {scope}",
|
|
974
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
975
|
+
error_code=RuntimeErrorCode.SCOPE_INVALID,
|
|
976
|
+
)
|
|
977
|
+
if network not in ("direct", "proxy"):
|
|
978
|
+
raise CliError(
|
|
979
|
+
message=f"Invalid network (must be direct|proxy): {network}",
|
|
980
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
981
|
+
error_code=RuntimeErrorCode.NETWORK_INVALID,
|
|
982
|
+
)
|
|
983
|
+
if max_memory is not None and not _MEMORY_LIMIT_RE.fullmatch(max_memory):
|
|
984
|
+
raise CliError(
|
|
985
|
+
message=(
|
|
986
|
+
"Invalid max memory (must be digits with an optional "
|
|
987
|
+
"b/k/m/g suffix): " + str(max_memory)
|
|
988
|
+
),
|
|
989
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
990
|
+
error_code="AISC_ERR_USAGE",
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
ws_path = Path(workspace).resolve()
|
|
994
|
+
if not (ws_path.exists() and ws_path.is_dir()):
|
|
995
|
+
raise CliError(
|
|
996
|
+
message=f"Workspace does not exist or is not a directory: {ws_path}",
|
|
997
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
998
|
+
error_code=RuntimeErrorCode.WORKSPACE_INVALID,
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
canonical_workspace = str(ws_path)
|
|
1002
|
+
|
|
1003
|
+
# IDEA-2 (缺口①): the Workbench never passed --proxy-config, so proxy
|
|
1004
|
+
# launches got TUN caps with nothing mounted and the entrypoint silently
|
|
1005
|
+
# skipped mihomo. Auto-resolve from the data root (adopting the legacy
|
|
1006
|
+
# repo-root config once); an explicit --proxy-config still wins.
|
|
1007
|
+
resolved_proxy = proxy_config
|
|
1008
|
+
if network == "proxy" and not resolved_proxy:
|
|
1009
|
+
from aisc.application.network_subscription import (
|
|
1010
|
+
resolve_subscription_config_path,
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
resolved_proxy = resolve_subscription_config_path()
|
|
1014
|
+
|
|
1015
|
+
fingerprint = compute_config_fingerprint(
|
|
1016
|
+
image, network, scope, canonical_workspace,
|
|
1017
|
+
proxy_config_sha256=_proxy_config_sha256(network, resolved_proxy),
|
|
1018
|
+
)
|
|
1019
|
+
ws_key = workspace_key_for(canonical_workspace)
|
|
1020
|
+
container_name = container_name_for(runtime_id)
|
|
1021
|
+
reg_root = _resolve_registry_root(canonical_workspace, registry_root)
|
|
1022
|
+
|
|
1023
|
+
# S8d: no registry history for this workspace ⇒ its first-ever start
|
|
1024
|
+
# (cold) — widen the ready wait. Explicit overrides win.
|
|
1025
|
+
if ready_timeout == _READY_DEFAULT_TIMEOUT and not _registry_has_history(reg_root):
|
|
1026
|
+
ready_timeout = _READY_COLD_TIMEOUT
|
|
1027
|
+
from aisc.applog import append_event as _append
|
|
1028
|
+
|
|
1029
|
+
_append("runtime_cold_start", source="cli", runtime_id=runtime_id,
|
|
1030
|
+
ready_timeout=ready_timeout)
|
|
1031
|
+
|
|
1032
|
+
_require_docker(executor)
|
|
1033
|
+
_require_image(image, executor)
|
|
1034
|
+
|
|
1035
|
+
# docker-resource 02 §4.1 (C1b): the default-tag → image-ID
|
|
1036
|
+
# resolution and the container create are a critical section vs.
|
|
1037
|
+
# installer no-cache rebuilds (a retag between resolve and create
|
|
1038
|
+
# would pin a brand-new runtime to a stale image). Lock ORDER is
|
|
1039
|
+
# frozen: maintenance -> workspace -> registry (never reversed;
|
|
1040
|
+
# reconcile follows the same order).
|
|
1041
|
+
from aisc.adapters.maintenance_lock import docker_maintenance_lock_at_root
|
|
1042
|
+
from aisc.application.data_root import shared_root
|
|
1043
|
+
|
|
1044
|
+
with docker_maintenance_lock_at_root(shared_root()):
|
|
1045
|
+
# 容器随镜像同步更新 (KI-4 挂账): the image ID recorded at create time;
|
|
1046
|
+
# "" when unobservable (transient probe failure) — the conflict check
|
|
1047
|
+
# then simply skips the comparison for this start.
|
|
1048
|
+
image_id_at_start = _resolve_image_id(image, executor) or ""
|
|
1049
|
+
|
|
1050
|
+
# --- workspace lock covers conflict check -> create -> ready -> commit ---
|
|
1051
|
+
# Timeout must exceed ready_timeout so a concurrent project start on the same
|
|
1052
|
+
# workspace waits for the winner (then sees its registry entry -> conflict)
|
|
1053
|
+
# rather than spuriously failing with STATE_LOCK_TIMEOUT.
|
|
1054
|
+
with workspace_lock(reg_root, ws_key, timeout=ready_timeout + 30.0):
|
|
1055
|
+
# Re-validate inside the lock; do not trust client preflight.
|
|
1056
|
+
# conflict_check (PreflightCheck) is advisory only and unused here;
|
|
1057
|
+
# start decides from matching_runtime_id / conflicts / matching_state.
|
|
1058
|
+
_conflict_check, matching_runtime_id, conflicts, matching_state = (
|
|
1059
|
+
_check_runtime_conflict(
|
|
1060
|
+
runtime_id=runtime_id,
|
|
1061
|
+
workspace=canonical_workspace,
|
|
1062
|
+
image=image,
|
|
1063
|
+
network=network,
|
|
1064
|
+
scope=scope,
|
|
1065
|
+
registry_root=reg_root,
|
|
1066
|
+
docker_available=True,
|
|
1067
|
+
executor=executor,
|
|
1068
|
+
proxy_config=resolved_proxy,
|
|
1069
|
+
)
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
# Idempotent reuse: same runtime_id + same fingerprint already registered.
|
|
1073
|
+
if matching_runtime_id == runtime_id:
|
|
1074
|
+
reg_entries = list_containers_readonly(reg_root)
|
|
1075
|
+
reuse_name = ""
|
|
1076
|
+
reuse_meta: Dict[str, Any] = {}
|
|
1077
|
+
for nm, meta in reg_entries.items():
|
|
1078
|
+
if isinstance(meta, dict) and meta.get("runtime_id") == runtime_id \
|
|
1079
|
+
and meta.get("config_fingerprint") == fingerprint:
|
|
1080
|
+
reuse_name = nm
|
|
1081
|
+
reuse_meta = meta
|
|
1082
|
+
break
|
|
1083
|
+
# 挂账 heal: legacy records carry no image_id — record the
|
|
1084
|
+
# current image's ID in place so future rebuilds under the same
|
|
1085
|
+
# tag are detected. svc-2 adds the same in-place heal for the
|
|
1086
|
+
# gateway host port (Docker-observed, only when a mapping really
|
|
1087
|
+
# exists). set_default=False: a metadata fix must not steal the
|
|
1088
|
+
# registry default from another container.
|
|
1089
|
+
healed = dict(reuse_meta)
|
|
1090
|
+
changed = False
|
|
1091
|
+
if reuse_name and not str(healed.get("image_id") or "").strip() \
|
|
1092
|
+
and image_id_at_start:
|
|
1093
|
+
healed["image_id"] = image_id_at_start
|
|
1094
|
+
changed = True
|
|
1095
|
+
if reuse_name and not int(healed.get("web_gateway_host_port") or 0):
|
|
1096
|
+
from aisc.application.web_gateway import read_gateway_mapping
|
|
1097
|
+
|
|
1098
|
+
mapping = read_gateway_mapping(executor, reuse_name)
|
|
1099
|
+
if mapping and mapping.get("active"):
|
|
1100
|
+
healed["web_gateway_host_port"] = mapping["host_port"]
|
|
1101
|
+
changed = True
|
|
1102
|
+
if reuse_name and changed:
|
|
1103
|
+
register(reg_root, reuse_name, healed, set_default=False)
|
|
1104
|
+
from aisc.applog import append_event
|
|
1105
|
+
|
|
1106
|
+
append_event("container_reused", source="cli",
|
|
1107
|
+
container=reuse_name, runtime_id=runtime_id,
|
|
1108
|
+
state=matching_state or "unknown")
|
|
1109
|
+
state = matching_state or "unknown"
|
|
1110
|
+
ready = state == "running"
|
|
1111
|
+
# A stopped matching runtime cannot satisfy `start`'s running
|
|
1112
|
+
# contract; restart it so the caller gets a ready runtime rather
|
|
1113
|
+
# than a reused-but-stopped limbo. `reused` stays True (the
|
|
1114
|
+
# container itself is reused, not freshly created).
|
|
1115
|
+
if state == "stopped":
|
|
1116
|
+
start_proc = executor.run_captured(["start", reuse_name], timeout=30.0)
|
|
1117
|
+
if start_proc.exit_code != 0:
|
|
1118
|
+
raise CliError(
|
|
1119
|
+
message=f"Failed to restart reused runtime: {(start_proc.stderr or '').strip()}",
|
|
1120
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1121
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1122
|
+
data={"container_name": reuse_name, "reused": True},
|
|
1123
|
+
)
|
|
1124
|
+
ctx = _wait_ready(executor, reuse_name, runtime_id, ready_timeout)
|
|
1125
|
+
if ctx is None:
|
|
1126
|
+
raise CliError(
|
|
1127
|
+
message=f"Reused runtime did not become ready within {ready_timeout}s",
|
|
1128
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1129
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1130
|
+
data={"container_name": reuse_name, "reused": True},
|
|
1131
|
+
)
|
|
1132
|
+
state = "running"
|
|
1133
|
+
ready = True
|
|
1134
|
+
return _build_start_payload(
|
|
1135
|
+
runtime_id, canonical_workspace, image, network, scope,
|
|
1136
|
+
reuse_name, reuse_meta.get("container_id", ""),
|
|
1137
|
+
state, ready, reused=True, fingerprint=fingerprint,
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
# Any conflict blocks creation.
|
|
1141
|
+
if conflicts:
|
|
1142
|
+
detail = "; ".join(
|
|
1143
|
+
f"{c.get('container_name', c.get('runtime_id', '?'))}: {c.get('reason', '')}"
|
|
1144
|
+
for c in conflicts
|
|
1145
|
+
)
|
|
1146
|
+
raise CliError(
|
|
1147
|
+
message=f"Runtime conflict prevents start: {detail}",
|
|
1148
|
+
exit_code=RuntimeExitCode.RUNTIME_CONFLICT,
|
|
1149
|
+
error_code=RuntimeErrorCode.RUNTIME_CONFLICT,
|
|
1150
|
+
data={"conflicts": conflicts, "config_fingerprint": fingerprint},
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
# --- create detached idle container ---
|
|
1154
|
+
from aisc.domain.docker_ownership import label_args, runtime_labels
|
|
1155
|
+
|
|
1156
|
+
argv = [
|
|
1157
|
+
"run", "-d",
|
|
1158
|
+
"--name", container_name,
|
|
1159
|
+
*label_args(runtime_labels(runtime_id, owner, ws_key)),
|
|
1160
|
+
# F2 (D-10): the container reaches the host MCP through the
|
|
1161
|
+
# host-gateway alias (Docker Desktop resolves it natively;
|
|
1162
|
+
# native-Linux engines need this mapping — T-F2a PoC).
|
|
1163
|
+
"--add-host=host.docker.internal:host-gateway",
|
|
1164
|
+
"-e", f"CLI_SCOPE={scope}",
|
|
1165
|
+
"-e", "AISC_RUNTIME_MODE=idle",
|
|
1166
|
+
"-e", f"AISC_RUNTIME_ID={runtime_id}",
|
|
1167
|
+
"-e", "TERM=xterm-256color",
|
|
1168
|
+
# v2.1.8 T2: workspace key for the SQLite bash history chain
|
|
1169
|
+
# (raw hex — distinct from the sha256-v1- directory name).
|
|
1170
|
+
"-e", f"AISC_WORKSPACE_HASH={ws_key}",
|
|
1171
|
+
# 2.1.9 T3b (R1): artifact registry bridge mount — the
|
|
1172
|
+
# container-side `aisc` shim writes the same JSONL registry
|
|
1173
|
+
# the host CLI/Workbench reads. Created host-side so the
|
|
1174
|
+
# bind mount is real.
|
|
1175
|
+
"-e", "AISC_ARTIFACT_ROOT=/root/.local/state/aisc-artifacts",
|
|
1176
|
+
# runtime-lifecycle-ux 3a: the entrypoint stamps the toolchain
|
|
1177
|
+
# environment baseline with the content-addressed image id.
|
|
1178
|
+
"-e", f"AISC_IMAGE_ID={image_id_at_start}",
|
|
1179
|
+
# F2 (D-10): host-tools MCP endpoint (full URL incl. token
|
|
1180
|
+
# query). Absent = the feature is off; the entrypoint then
|
|
1181
|
+
# leaves the agents' MCP registrations untouched.
|
|
1182
|
+
*(["-e", f"AISC_HOST_MCP_URL={host_mcp_url}"] if host_mcp_url else []),
|
|
1183
|
+
# PERF P8 (D-13): low-spec resource limits — the container's
|
|
1184
|
+
# own budget, distinct from the WSL2 VM's. Only new containers;
|
|
1185
|
+
# a rebuild (remove + start) is how existing ones pick it up.
|
|
1186
|
+
*(["--memory", max_memory] if max_memory else []),
|
|
1187
|
+
*(["--cpus", str(max_cpus)] if max_cpus is not None else []),
|
|
1188
|
+
"-v", f"{canonical_workspace}:/root/app",
|
|
1189
|
+
]
|
|
1190
|
+
# svc-2 (web gateway): allocate a loopback host port and publish the
|
|
1191
|
+
# container-side gateway. Bind conflicts retry with the next free
|
|
1192
|
+
# port; the host port is runtime metadata only — never part of the
|
|
1193
|
+
# config fingerprint (decisions.md §7).
|
|
1194
|
+
from aisc.application.web_gateway import (
|
|
1195
|
+
allocate_gateway_host_port,
|
|
1196
|
+
docker_publish_argv,
|
|
1197
|
+
is_bind_conflict,
|
|
1198
|
+
registry_host_ports,
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
gw_exclude = registry_host_ports(list_containers_readonly(reg_root))
|
|
1202
|
+
gw_host_port = allocate_gateway_host_port(exclude=gw_exclude)
|
|
1203
|
+
# 2.1.9 T3b (R1): host-side artifacts root for the container
|
|
1204
|
+
# bridge mount (see the AISC_ARTIFACT_ROOT env above).
|
|
1205
|
+
from aisc.application.data_root import shared_root as _shared_root
|
|
1206
|
+
|
|
1207
|
+
artifacts_root = _shared_root() / "artifacts"
|
|
1208
|
+
artifacts_root.mkdir(parents=True, exist_ok=True)
|
|
1209
|
+
argv.extend([
|
|
1210
|
+
"-v", f"{artifacts_root}:/root/.local/state/aisc-artifacts",
|
|
1211
|
+
])
|
|
1212
|
+
# Stage 7 (DATA-01): project-scope agent config mounts from the
|
|
1213
|
+
# data root; dirs are created host-side so bind mounts are real.
|
|
1214
|
+
# Fail closed: resolver errors propagate — never copy agent state
|
|
1215
|
+
# into the workspace again.
|
|
1216
|
+
toolchain_storage = ""
|
|
1217
|
+
if scope not in ("temporary", "temp", "global"):
|
|
1218
|
+
from aisc.application.data_root import DataRootResolver
|
|
1219
|
+
from aisc.application.toolchain import (
|
|
1220
|
+
prepare_toolchain,
|
|
1221
|
+
toolchain_mount_argv,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
ws_state_dir = DataRootResolver().resolve(
|
|
1225
|
+
Path(canonical_workspace)
|
|
1226
|
+
).workspace_dir
|
|
1227
|
+
for sub in ("claude", "codex", "cc-switch", "runtime"):
|
|
1228
|
+
(ws_state_dir / sub).mkdir(parents=True, exist_ok=True)
|
|
1229
|
+
argv.extend([
|
|
1230
|
+
"-v", f"{ws_state_dir / 'claude'}:/root/.claude",
|
|
1231
|
+
"-v", f"{ws_state_dir / 'codex'}:/root/.codex",
|
|
1232
|
+
"-v", f"{ws_state_dir / 'cc-switch'}:/root/.cc-switch",
|
|
1233
|
+
"-v", f"{ws_state_dir / 'runtime'}:/root/.local/state/cc-switch",
|
|
1234
|
+
])
|
|
1235
|
+
# runtime-lifecycle-ux 3a: project scope mounts the persistent
|
|
1236
|
+
# toolchain (host_bind per the Windows spike decision). Failures
|
|
1237
|
+
# to prepare propagate — a broken toolchain dir must not be
|
|
1238
|
+
# silently swallowed into a runtime without it.
|
|
1239
|
+
prepare_toolchain(ws_state_dir, image_id=image_id_at_start)
|
|
1240
|
+
argv.extend(toolchain_mount_argv(ws_state_dir))
|
|
1241
|
+
toolchain_storage = "host_bind"
|
|
1242
|
+
if network == "proxy":
|
|
1243
|
+
argv.extend(["--cap-add=NET_ADMIN", "--device", "/dev/net/tun"])
|
|
1244
|
+
if resolved_proxy:
|
|
1245
|
+
argv.extend(["-v", f"{resolved_proxy}:/etc/mihomo/config.yaml:ro"])
|
|
1246
|
+
argv.append(image)
|
|
1247
|
+
|
|
1248
|
+
base_argv = argv[:-1] # everything except the image; publish goes last
|
|
1249
|
+
proc = None
|
|
1250
|
+
for _attempt in range(5):
|
|
1251
|
+
argv = base_argv + docker_publish_argv(gw_host_port) + [image]
|
|
1252
|
+
proc = executor.run_captured(argv, timeout=60.0)
|
|
1253
|
+
if proc.exit_code == 0 and (proc.stdout or "").strip():
|
|
1254
|
+
break
|
|
1255
|
+
stderr = (proc.stderr or "").strip()
|
|
1256
|
+
if is_bind_conflict(stderr) and _attempt < 4:
|
|
1257
|
+
gw_exclude.add(gw_host_port)
|
|
1258
|
+
gw_host_port = allocate_gateway_host_port(
|
|
1259
|
+
exclude=gw_exclude, start_hint=gw_host_port + 1)
|
|
1260
|
+
continue
|
|
1261
|
+
raise CliError(
|
|
1262
|
+
message=f"Failed to create runtime container: {stderr}",
|
|
1263
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1264
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1265
|
+
data={"container_name": container_name, "config_fingerprint": fingerprint},
|
|
1266
|
+
)
|
|
1267
|
+
container_id = proc.stdout.strip()
|
|
1268
|
+
|
|
1269
|
+
from aisc.applog import append_event
|
|
1270
|
+
|
|
1271
|
+
append_event("container_created", source="cli",
|
|
1272
|
+
container=container_name, runtime_id=runtime_id, image=image)
|
|
1273
|
+
append_event("web_gateway_allocated", source="cli",
|
|
1274
|
+
container=container_name, runtime_id=runtime_id,
|
|
1275
|
+
container_port=45871, host_port=gw_host_port,
|
|
1276
|
+
protocol="http")
|
|
1277
|
+
|
|
1278
|
+
# --- ready check ---
|
|
1279
|
+
ctx = _wait_ready(executor, container_name, runtime_id, ready_timeout)
|
|
1280
|
+
if ctx is None:
|
|
1281
|
+
# Best-effort cleanup; report partial identity.
|
|
1282
|
+
_safe_remove(executor, container_name)
|
|
1283
|
+
append_event("container_ready_timeout", level="error", source="cli",
|
|
1284
|
+
container=container_name, runtime_id=runtime_id)
|
|
1285
|
+
raise CliError(
|
|
1286
|
+
message=f"Runtime container did not become ready within {ready_timeout}s",
|
|
1287
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1288
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1289
|
+
data={
|
|
1290
|
+
"container_name": container_name,
|
|
1291
|
+
"container_id": container_id,
|
|
1292
|
+
"config_fingerprint": fingerprint,
|
|
1293
|
+
},
|
|
1294
|
+
)
|
|
1295
|
+
append_event("container_ready", source="cli",
|
|
1296
|
+
container=container_name, runtime_id=runtime_id)
|
|
1297
|
+
append_event("web_gateway_ready", source="cli",
|
|
1298
|
+
container=container_name, runtime_id=runtime_id,
|
|
1299
|
+
container_port=45871, host_port=gw_host_port,
|
|
1300
|
+
protocol="http")
|
|
1301
|
+
|
|
1302
|
+
# --- commit registry entry (registry lock acquired inside register) ---
|
|
1303
|
+
try:
|
|
1304
|
+
register(reg_root, container_name, {
|
|
1305
|
+
"image": image,
|
|
1306
|
+
"workspace": canonical_workspace,
|
|
1307
|
+
"network": network,
|
|
1308
|
+
"label": "",
|
|
1309
|
+
"runtime_id": runtime_id,
|
|
1310
|
+
"owner": owner,
|
|
1311
|
+
"scope": scope,
|
|
1312
|
+
"config_fingerprint": fingerprint,
|
|
1313
|
+
"container_id": container_id,
|
|
1314
|
+
"workspace_key": ws_key,
|
|
1315
|
+
"image_id": image_id_at_start,
|
|
1316
|
+
"web_gateway_host_port": gw_host_port,
|
|
1317
|
+
# runtime-lifecycle-ux Stage 1 (02 §1): session-ephemeral by
|
|
1318
|
+
# default; dependency_policy derives from scope (D-RUNTIME-09).
|
|
1319
|
+
"lifecycle": "ephemeral",
|
|
1320
|
+
"retention": "remove_on_close",
|
|
1321
|
+
"dependency_policy": (
|
|
1322
|
+
"persistent_toolchain" if scope == "project" else "ephemeral_toolchain"
|
|
1323
|
+
),
|
|
1324
|
+
"toolchain_storage": toolchain_storage,
|
|
1325
|
+
})
|
|
1326
|
+
except CliError:
|
|
1327
|
+
# register raises CliError(STATE_LOCK_TIMEOUT) if the registry lock
|
|
1328
|
+
# times out. Cleanup the just-created container but preserve the
|
|
1329
|
+
# original error code (do not remap to RUNTIME_OPERATION_FAILED).
|
|
1330
|
+
_safe_remove(executor, container_name)
|
|
1331
|
+
raise
|
|
1332
|
+
except (ValueError, OSError) as exc:
|
|
1333
|
+
# Registry commit failed: remove the new container, report partial.
|
|
1334
|
+
cleanup_ok = _safe_remove(executor, container_name)
|
|
1335
|
+
raise CliError(
|
|
1336
|
+
message=(
|
|
1337
|
+
f"Failed to commit registry: {exc}. "
|
|
1338
|
+
f"Container cleanup {'succeeded' if cleanup_ok else 'FAILED - orphaned'}."
|
|
1339
|
+
),
|
|
1340
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1341
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1342
|
+
data={
|
|
1343
|
+
"container_name": container_name,
|
|
1344
|
+
"container_id": container_id,
|
|
1345
|
+
"config_fingerprint": fingerprint,
|
|
1346
|
+
"cleanup_ok": cleanup_ok,
|
|
1347
|
+
},
|
|
1348
|
+
) from exc
|
|
1349
|
+
|
|
1350
|
+
return _build_start_payload(
|
|
1351
|
+
runtime_id, canonical_workspace, image, network, scope,
|
|
1352
|
+
container_name, container_id,
|
|
1353
|
+
state="running", ready=True, reused=False, fingerprint=fingerprint,
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _safe_remove(executor: Any, container_name: str) -> bool:
|
|
1358
|
+
"""Best-effort ``docker rm -f``; returns True if it succeeded."""
|
|
1359
|
+
try:
|
|
1360
|
+
result = executor.remove_container(container_name, force=True)
|
|
1361
|
+
return result.exit_code == 0
|
|
1362
|
+
except Exception:
|
|
1363
|
+
return False
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _snapshot_from_registry(
|
|
1367
|
+
name: str,
|
|
1368
|
+
meta: Dict[str, Any],
|
|
1369
|
+
docker_state: Optional[str],
|
|
1370
|
+
observed_at: str,
|
|
1371
|
+
registry_state: str = "registered",
|
|
1372
|
+
) -> RuntimeSnapshot:
|
|
1373
|
+
"""Build a RuntimeSnapshot from a registry entry + observed Docker state.
|
|
1374
|
+
|
|
1375
|
+
``registry_state`` is "registered" when the meta came from the registry,
|
|
1376
|
+
or "missing" when the container was discovered via Docker labels only.
|
|
1377
|
+
runtime-lifecycle-ux 3a: project runtimes also carry the dependency
|
|
1378
|
+
policy + host-side toolchain health (advisory; stat-only, never gates).
|
|
1379
|
+
"""
|
|
1380
|
+
state = docker_state or "not_found"
|
|
1381
|
+
snapshot = RuntimeSnapshot(
|
|
1382
|
+
runtime_id=meta.get("runtime_id", ""),
|
|
1383
|
+
state=state,
|
|
1384
|
+
workspace=meta.get("workspace", ""),
|
|
1385
|
+
image=meta.get("image", ""),
|
|
1386
|
+
network=meta.get("network", "direct"),
|
|
1387
|
+
scope=meta.get("scope", "project"),
|
|
1388
|
+
owner=meta.get("owner", ""),
|
|
1389
|
+
config_fingerprint=meta.get("config_fingerprint", ""),
|
|
1390
|
+
container_name=name,
|
|
1391
|
+
container_id=meta.get("container_id", ""),
|
|
1392
|
+
registry_state=registry_state,
|
|
1393
|
+
observed_at=observed_at,
|
|
1394
|
+
)
|
|
1395
|
+
policy = str(meta.get("dependency_policy") or "")
|
|
1396
|
+
if not policy:
|
|
1397
|
+
policy = "persistent_toolchain" if snapshot.scope == "project" else "ephemeral_toolchain"
|
|
1398
|
+
snapshot.dependency_policy = policy
|
|
1399
|
+
if meta.get("toolchain_storage") == "host_bind" and snapshot.workspace:
|
|
1400
|
+
try:
|
|
1401
|
+
from aisc.application.data_root import DataRootResolver
|
|
1402
|
+
from aisc.application.toolchain import toolchain_health
|
|
1403
|
+
|
|
1404
|
+
ws_dir = DataRootResolver().resolve(Path(snapshot.workspace)).workspace_dir
|
|
1405
|
+
snapshot.toolchain = toolchain_health(ws_dir)
|
|
1406
|
+
except Exception:
|
|
1407
|
+
pass # advisory only — an unresolvable data root is not a failure
|
|
1408
|
+
return snapshot
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def list_runtimes(
|
|
1412
|
+
executor: Any,
|
|
1413
|
+
registry_root: Path,
|
|
1414
|
+
owner: Optional[str] = None,
|
|
1415
|
+
workspace: Optional[str] = None,
|
|
1416
|
+
) -> List[RuntimeSnapshot]:
|
|
1417
|
+
"""List runtimes with registry/Docker reconciliation per contract §5.3.
|
|
1418
|
+
|
|
1419
|
+
Docker unavailable -> CliError(3); never disguises cached state as live.
|
|
1420
|
+
Containers present in Docker but absent from registry are marked
|
|
1421
|
+
``registry_state: "missing"`` and never auto-deleted.
|
|
1422
|
+
"""
|
|
1423
|
+
from aisc.adapters.container_registry import list_containers
|
|
1424
|
+
|
|
1425
|
+
_require_docker(executor)
|
|
1426
|
+
|
|
1427
|
+
canonical_workspace = str(Path(workspace).resolve()) if workspace else None
|
|
1428
|
+
ws_key = workspace_key_for(canonical_workspace) if canonical_workspace else None
|
|
1429
|
+
observed_at = iso_now()
|
|
1430
|
+
|
|
1431
|
+
reg_entries = list_containers(registry_root)
|
|
1432
|
+
|
|
1433
|
+
# Docker-managed runtime containers for this workspace (or all if no ws).
|
|
1434
|
+
docker_containers = _list_docker_runtime_containers(executor, workspace_key=ws_key)
|
|
1435
|
+
docker_by_name = {dc["container_name"]: dc for dc in docker_containers}
|
|
1436
|
+
|
|
1437
|
+
snapshots: List[RuntimeSnapshot] = []
|
|
1438
|
+
seen_names: set = set()
|
|
1439
|
+
|
|
1440
|
+
for name, meta in reg_entries.items():
|
|
1441
|
+
if not isinstance(meta, dict):
|
|
1442
|
+
continue
|
|
1443
|
+
if owner is not None and meta.get("owner", "") != owner:
|
|
1444
|
+
continue
|
|
1445
|
+
if canonical_workspace is not None:
|
|
1446
|
+
try:
|
|
1447
|
+
if str(Path(meta.get("workspace", "")).resolve()) != canonical_workspace:
|
|
1448
|
+
continue
|
|
1449
|
+
except Exception:
|
|
1450
|
+
continue
|
|
1451
|
+
dc = docker_by_name.get(name)
|
|
1452
|
+
docker_state = dc["state"] if dc else None
|
|
1453
|
+
snapshots.append(_snapshot_from_registry(name, meta, docker_state, observed_at))
|
|
1454
|
+
seen_names.add(name)
|
|
1455
|
+
|
|
1456
|
+
# Docker-only (registry missing) containers for this workspace.
|
|
1457
|
+
for dc in docker_containers:
|
|
1458
|
+
name = dc["container_name"]
|
|
1459
|
+
if name in seen_names:
|
|
1460
|
+
continue
|
|
1461
|
+
if owner is not None and dc.get("owner", "") != owner:
|
|
1462
|
+
continue
|
|
1463
|
+
snapshots.append(RuntimeSnapshot(
|
|
1464
|
+
runtime_id=dc.get("runtime_id", ""),
|
|
1465
|
+
state=dc.get("state", "unknown"),
|
|
1466
|
+
workspace=canonical_workspace or "",
|
|
1467
|
+
image=dc.get("image", ""),
|
|
1468
|
+
network="",
|
|
1469
|
+
scope="",
|
|
1470
|
+
owner=dc.get("owner", ""),
|
|
1471
|
+
config_fingerprint="",
|
|
1472
|
+
container_name=name,
|
|
1473
|
+
container_id=dc.get("container_id", ""),
|
|
1474
|
+
registry_state="missing",
|
|
1475
|
+
observed_at=observed_at,
|
|
1476
|
+
))
|
|
1477
|
+
|
|
1478
|
+
return snapshots
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _web_access_for(executor: Any, container_name: str, docker_state: Optional[str],
|
|
1482
|
+
meta: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
1483
|
+
"""svc-2: snapshot_web_access wrapped for RuntimeSnapshot.web_access."""
|
|
1484
|
+
from aisc.application.web_gateway import snapshot_web_access
|
|
1485
|
+
|
|
1486
|
+
return snapshot_web_access(
|
|
1487
|
+
executor, container_name, docker_state,
|
|
1488
|
+
registry_has_gateway=bool((meta or {}).get("web_gateway_host_port")),
|
|
1489
|
+
).to_dict()
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def inspect_runtime(
|
|
1493
|
+
runtime_id: str,
|
|
1494
|
+
executor: Any,
|
|
1495
|
+
registry_root: Path,
|
|
1496
|
+
) -> RuntimeSnapshot:
|
|
1497
|
+
"""Return a single runtime snapshot per contract §5.4.
|
|
1498
|
+
|
|
1499
|
+
Distinguishes ``not_found`` (Docker & registry both absent), ``stopped``
|
|
1500
|
+
(container exists, not running) and ``unknown`` (Docker daemon/permission
|
|
1501
|
+
unavailable). svc-2: also carries ``web_access`` (gateway reachability;
|
|
1502
|
+
absent on the list path).
|
|
1503
|
+
"""
|
|
1504
|
+
from aisc.adapters.container_registry import find_by_runtime_id
|
|
1505
|
+
from aisc.domain.web_services import web_access_unavailable
|
|
1506
|
+
|
|
1507
|
+
if not validate_uuid_v4(runtime_id):
|
|
1508
|
+
raise CliError(
|
|
1509
|
+
message=f"Invalid runtime ID (must be UUID v4): {runtime_id}",
|
|
1510
|
+
exit_code=RuntimeExitCode.INVALID_RUNTIME_ID,
|
|
1511
|
+
error_code=RuntimeErrorCode.INVALID_RUNTIME_ID,
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
observed_at = iso_now()
|
|
1515
|
+
|
|
1516
|
+
# Docker unavailable -> cannot confirm actual state.
|
|
1517
|
+
if not _check_docker(executor):
|
|
1518
|
+
return RuntimeSnapshot(
|
|
1519
|
+
runtime_id=runtime_id,
|
|
1520
|
+
state="unknown",
|
|
1521
|
+
registry_state="unknown",
|
|
1522
|
+
observed_at=observed_at,
|
|
1523
|
+
stale=True,
|
|
1524
|
+
web_access=web_access_unavailable("docker_unavailable").to_dict(),
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
found = find_by_runtime_id(registry_root, runtime_id)
|
|
1528
|
+
reg_name, reg_meta = (found if found else (None, None))
|
|
1529
|
+
dc = _find_docker_container_by_runtime_id(runtime_id, executor)
|
|
1530
|
+
|
|
1531
|
+
if reg_name is None and dc is None:
|
|
1532
|
+
return RuntimeSnapshot(
|
|
1533
|
+
runtime_id=runtime_id,
|
|
1534
|
+
state="not_found",
|
|
1535
|
+
registry_state="not_found",
|
|
1536
|
+
observed_at=observed_at,
|
|
1537
|
+
web_access=web_access_unavailable("runtime_not_running").to_dict(),
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
if reg_name is not None and reg_meta is not None:
|
|
1541
|
+
docker_state = dc["state"] if dc else "not_found"
|
|
1542
|
+
snapshot = _snapshot_from_registry(reg_name, reg_meta, docker_state, observed_at)
|
|
1543
|
+
snapshot.web_access = _web_access_for(executor, reg_name, docker_state, reg_meta)
|
|
1544
|
+
return snapshot
|
|
1545
|
+
|
|
1546
|
+
# Docker-only (registry missing).
|
|
1547
|
+
docker_state = dc["state"] if dc else "not_found"
|
|
1548
|
+
container_name = dc["container_name"] if dc else ""
|
|
1549
|
+
return RuntimeSnapshot(
|
|
1550
|
+
runtime_id=runtime_id,
|
|
1551
|
+
state=docker_state,
|
|
1552
|
+
container_name=container_name,
|
|
1553
|
+
container_id=dc["container_id"] if dc else "",
|
|
1554
|
+
registry_state="missing",
|
|
1555
|
+
observed_at=observed_at,
|
|
1556
|
+
web_access=_web_access_for(executor, container_name, docker_state, None),
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
class _MemoExecutor:
|
|
1561
|
+
"""PERF P1: per-invocation result memo for the merged status path.
|
|
1562
|
+
|
|
1563
|
+
The snapshot and services halves resolve the SAME runtime independently
|
|
1564
|
+
(label-filtered ps + container inspect each); identical read calls within
|
|
1565
|
+
one invocation return the cached result. Only wraps read-shaped use —
|
|
1566
|
+
the status path issues no mutations."""
|
|
1567
|
+
|
|
1568
|
+
def __init__(self, inner: Any):
|
|
1569
|
+
self._inner = inner
|
|
1570
|
+
self._cache: dict = {}
|
|
1571
|
+
|
|
1572
|
+
def __getattr__(self, name: str):
|
|
1573
|
+
attr = getattr(self._inner, name)
|
|
1574
|
+
if not callable(attr):
|
|
1575
|
+
return attr
|
|
1576
|
+
|
|
1577
|
+
def wrapper(*args, **kwargs):
|
|
1578
|
+
key = (name, repr(args), tuple(sorted(kwargs.items())))
|
|
1579
|
+
if key not in self._cache:
|
|
1580
|
+
self._cache[key] = attr(*args, **kwargs)
|
|
1581
|
+
return self._cache[key]
|
|
1582
|
+
|
|
1583
|
+
return wrapper
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def runtime_status(
|
|
1587
|
+
runtime_id: str,
|
|
1588
|
+
executor: Any,
|
|
1589
|
+
registry_root: Path,
|
|
1590
|
+
services_deadline_s: float = 3.0,
|
|
1591
|
+
) -> dict:
|
|
1592
|
+
"""PERF P1: ``aisc runtime status`` — inspect + services in ONE CLI
|
|
1593
|
+
invocation (each spawn costs ~750ms of pure process overhead; the 5s
|
|
1594
|
+
poll tick used to pay it twice).
|
|
1595
|
+
|
|
1596
|
+
The snapshot is authoritative; ``services`` is a best-effort NESTED
|
|
1597
|
+
field computed under a deadline — timeout/failure yields ``None`` and
|
|
1598
|
+
never drags the snapshot down (keeps the poll's stale/fresh semantics
|
|
1599
|
+
clean AND keeps a slow services half from polluting the backoff
|
|
1600
|
+
ladder's slow signal with foreign latency)."""
|
|
1601
|
+
import threading
|
|
1602
|
+
|
|
1603
|
+
memo = _MemoExecutor(executor)
|
|
1604
|
+
snapshot = inspect_runtime(
|
|
1605
|
+
runtime_id=runtime_id, executor=memo, registry_root=registry_root
|
|
1606
|
+
).to_dict()
|
|
1607
|
+
|
|
1608
|
+
services: Any = None
|
|
1609
|
+
box: dict = {}
|
|
1610
|
+
|
|
1611
|
+
def _collect():
|
|
1612
|
+
try:
|
|
1613
|
+
from aisc.application.web_gateway import runtime_services
|
|
1614
|
+
|
|
1615
|
+
box["r"] = runtime_services(
|
|
1616
|
+
runtime_id=runtime_id,
|
|
1617
|
+
executor=memo,
|
|
1618
|
+
registry_root=registry_root,
|
|
1619
|
+
).to_dict()
|
|
1620
|
+
except Exception:
|
|
1621
|
+
box["error"] = True # best-effort: swallowed, services stays None
|
|
1622
|
+
|
|
1623
|
+
t = threading.Thread(target=_collect, daemon=True)
|
|
1624
|
+
t.start()
|
|
1625
|
+
t.join(services_deadline_s)
|
|
1626
|
+
if "r" in box:
|
|
1627
|
+
services = box["r"]
|
|
1628
|
+
return {"snapshot": snapshot, "services": services}
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def _resolve_container_for_lifecycle(
|
|
1632
|
+
runtime_id: str,
|
|
1633
|
+
executor: Any,
|
|
1634
|
+
registry_root: Path,
|
|
1635
|
+
) -> Tuple[str, str, Dict[str, Any], str]:
|
|
1636
|
+
"""Resolve (container_name, container_id, meta, registry_state) for
|
|
1637
|
+
stop/restart/remove.
|
|
1638
|
+
|
|
1639
|
+
Prefers the registry entry (registry_state="registered"); falls back to
|
|
1640
|
+
Docker label discovery (registry_state="missing"). Raises
|
|
1641
|
+
CliError(RUNTIME_NOT_FOUND) if absent from both.
|
|
1642
|
+
"""
|
|
1643
|
+
from aisc.adapters.container_registry import find_by_runtime_id
|
|
1644
|
+
|
|
1645
|
+
if not validate_uuid_v4(runtime_id):
|
|
1646
|
+
raise CliError(
|
|
1647
|
+
message=f"Invalid runtime ID (must be UUID v4): {runtime_id}",
|
|
1648
|
+
exit_code=RuntimeExitCode.INVALID_RUNTIME_ID,
|
|
1649
|
+
error_code=RuntimeErrorCode.INVALID_RUNTIME_ID,
|
|
1650
|
+
)
|
|
1651
|
+
_require_docker(executor)
|
|
1652
|
+
|
|
1653
|
+
found = find_by_runtime_id(registry_root, runtime_id)
|
|
1654
|
+
if found is not None:
|
|
1655
|
+
name, meta = found
|
|
1656
|
+
return name, meta.get("container_id", ""), meta, "registered"
|
|
1657
|
+
|
|
1658
|
+
dc = _find_docker_container_by_runtime_id(runtime_id, executor)
|
|
1659
|
+
if dc is not None:
|
|
1660
|
+
return dc["container_name"], dc["container_id"], {
|
|
1661
|
+
"runtime_id": runtime_id,
|
|
1662
|
+
"container_id": dc["container_id"],
|
|
1663
|
+
"workspace": "",
|
|
1664
|
+
"image": "",
|
|
1665
|
+
"network": "",
|
|
1666
|
+
"scope": "",
|
|
1667
|
+
"owner": "",
|
|
1668
|
+
"config_fingerprint": "",
|
|
1669
|
+
}, "missing"
|
|
1670
|
+
|
|
1671
|
+
raise CliError(
|
|
1672
|
+
message=f"Runtime not found: {runtime_id}",
|
|
1673
|
+
exit_code=RuntimeExitCode.GENERAL_ERROR,
|
|
1674
|
+
error_code=RuntimeErrorCode.RUNTIME_NOT_FOUND,
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def stop_runtime(
|
|
1679
|
+
runtime_id: str,
|
|
1680
|
+
executor: Any,
|
|
1681
|
+
registry_root: Path,
|
|
1682
|
+
grace_seconds: int = 10,
|
|
1683
|
+
) -> RuntimeSnapshot:
|
|
1684
|
+
"""Stop a runtime but keep container + registry metadata per §5.5.
|
|
1685
|
+
|
|
1686
|
+
Idempotent for a stopped-but-present container: ``docker stop`` on an
|
|
1687
|
+
already-stopped container is a no-op success. A runtime whose container
|
|
1688
|
+
is gone raises RUNTIME_NOT_FOUND. ``grace_seconds`` is passed through to
|
|
1689
|
+
``docker stop -t`` (CLI default 10; Workbench fast path 3).
|
|
1690
|
+
"""
|
|
1691
|
+
name, container_id, meta, registry_state = _resolve_container_for_lifecycle(
|
|
1692
|
+
runtime_id, executor, registry_root
|
|
1693
|
+
)
|
|
1694
|
+
result = executor.stop_container(name, timeout=grace_seconds)
|
|
1695
|
+
if result.exit_code != 0:
|
|
1696
|
+
stderr = (result.stderr or "").lower()
|
|
1697
|
+
if any(kw in stderr for kw in ("no such", "not found")):
|
|
1698
|
+
raise CliError(
|
|
1699
|
+
message=f"Runtime container not found: {name}",
|
|
1700
|
+
exit_code=RuntimeExitCode.GENERAL_ERROR,
|
|
1701
|
+
error_code=RuntimeErrorCode.RUNTIME_NOT_FOUND,
|
|
1702
|
+
)
|
|
1703
|
+
raise CliError(
|
|
1704
|
+
message=f"Failed to stop runtime: {(result.stderr or '').strip()}",
|
|
1705
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1706
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1707
|
+
)
|
|
1708
|
+
return _snapshot_from_registry(
|
|
1709
|
+
name, meta, "stopped", iso_now(), registry_state=registry_state
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def restart_runtime(
|
|
1714
|
+
runtime_id: str,
|
|
1715
|
+
executor: Any,
|
|
1716
|
+
registry_root: Path,
|
|
1717
|
+
ready_timeout: float = _READY_DEFAULT_TIMEOUT,
|
|
1718
|
+
) -> RuntimeSnapshot:
|
|
1719
|
+
"""Restart the same container with its original config per §5.5.
|
|
1720
|
+
|
|
1721
|
+
Waits for readiness before returning.
|
|
1722
|
+
"""
|
|
1723
|
+
name, container_id, meta, registry_state = _resolve_container_for_lifecycle(
|
|
1724
|
+
runtime_id, executor, registry_root
|
|
1725
|
+
)
|
|
1726
|
+
result = executor.run_captured(["start", name], timeout=30.0)
|
|
1727
|
+
if result.exit_code != 0:
|
|
1728
|
+
raise CliError(
|
|
1729
|
+
message=f"Failed to restart runtime: {(result.stderr or '').strip()}",
|
|
1730
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1731
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1732
|
+
)
|
|
1733
|
+
ctx = _wait_ready(executor, name, runtime_id, ready_timeout)
|
|
1734
|
+
if ctx is None:
|
|
1735
|
+
raise CliError(
|
|
1736
|
+
message=f"Restarted runtime did not become ready within {ready_timeout}s",
|
|
1737
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1738
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1739
|
+
)
|
|
1740
|
+
return _snapshot_from_registry(
|
|
1741
|
+
name, meta, "running", iso_now(), registry_state=registry_state
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def remove_runtime(
|
|
1746
|
+
runtime_id: str,
|
|
1747
|
+
executor: Any,
|
|
1748
|
+
registry_root: Path,
|
|
1749
|
+
force: bool = False,
|
|
1750
|
+
) -> RuntimeSnapshot:
|
|
1751
|
+
"""Delete the container and unregister the runtime per §5.5.
|
|
1752
|
+
|
|
1753
|
+
A running runtime is rejected unless ``force`` is set. Idempotent: removing
|
|
1754
|
+
an already-removed runtime returns a not_found snapshot (rc 0) per §十二.
|
|
1755
|
+
"""
|
|
1756
|
+
from aisc.adapters.container_registry import unregister_by_runtime_id
|
|
1757
|
+
|
|
1758
|
+
try:
|
|
1759
|
+
name, container_id, meta, _registry_state = _resolve_container_for_lifecycle(
|
|
1760
|
+
runtime_id, executor, registry_root
|
|
1761
|
+
)
|
|
1762
|
+
except CliError as exc:
|
|
1763
|
+
if exc.error_code == RuntimeErrorCode.RUNTIME_NOT_FOUND:
|
|
1764
|
+
# Idempotent: already removed -> not_found success (contract §十二).
|
|
1765
|
+
return RuntimeSnapshot(
|
|
1766
|
+
runtime_id=runtime_id,
|
|
1767
|
+
state="not_found",
|
|
1768
|
+
workspace="",
|
|
1769
|
+
image="",
|
|
1770
|
+
network="",
|
|
1771
|
+
scope="",
|
|
1772
|
+
owner="",
|
|
1773
|
+
config_fingerprint="",
|
|
1774
|
+
container_name="",
|
|
1775
|
+
container_id="",
|
|
1776
|
+
registry_state="not_found",
|
|
1777
|
+
observed_at=iso_now(),
|
|
1778
|
+
)
|
|
1779
|
+
raise
|
|
1780
|
+
|
|
1781
|
+
# Refuse to remove a running runtime without --force.
|
|
1782
|
+
state = _get_container_state(name, executor)
|
|
1783
|
+
if state == "running" and not force:
|
|
1784
|
+
raise CliError(
|
|
1785
|
+
message=(
|
|
1786
|
+
f"Runtime {runtime_id} is running. "
|
|
1787
|
+
"Stop it first or pass --force to remove a running runtime."
|
|
1788
|
+
),
|
|
1789
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1790
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1791
|
+
data={"runtime_id": runtime_id, "state": "running"},
|
|
1792
|
+
)
|
|
1793
|
+
|
|
1794
|
+
result = executor.remove_container(name, force=force)
|
|
1795
|
+
if result.exit_code != 0:
|
|
1796
|
+
stderr = (result.stderr or "").lower()
|
|
1797
|
+
if not any(kw in stderr for kw in ("no such", "not found")):
|
|
1798
|
+
raise CliError(
|
|
1799
|
+
message=f"Failed to remove runtime: {(result.stderr or '').strip()}",
|
|
1800
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
1801
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
unregister_by_runtime_id(registry_root, runtime_id)
|
|
1805
|
+
|
|
1806
|
+
from aisc.applog import append_event
|
|
1807
|
+
|
|
1808
|
+
append_event("container_removed", source="cli",
|
|
1809
|
+
container=name, runtime_id=runtime_id)
|
|
1810
|
+
|
|
1811
|
+
return RuntimeSnapshot(
|
|
1812
|
+
runtime_id=runtime_id,
|
|
1813
|
+
state="not_found",
|
|
1814
|
+
workspace=meta.get("workspace", ""),
|
|
1815
|
+
image=meta.get("image", ""),
|
|
1816
|
+
network=meta.get("network", ""),
|
|
1817
|
+
scope=meta.get("scope", ""),
|
|
1818
|
+
owner=meta.get("owner", ""),
|
|
1819
|
+
config_fingerprint=meta.get("config_fingerprint", ""),
|
|
1820
|
+
container_name=name,
|
|
1821
|
+
container_id=container_id,
|
|
1822
|
+
registry_state="not_found",
|
|
1823
|
+
observed_at=iso_now(),
|
|
1824
|
+
)
|
|
1825
|
+
|
|
1826
|
+
|