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