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,718 @@
1
+ """Container registry — multi-container index at ``.aisc/containers.json``.
2
+
3
+ Replaces the single-container ``CONTAINER_NAME`` pointer in ``state.env``.
4
+ Each ``aisc run`` registers a container; ``status``/``stop``/``restart``/
5
+ ``shell``/``switch`` discover the target via :func:`resolve_target`.
6
+
7
+ The registry is a JSON file with a ``default`` pointer and a ``containers``
8
+ map keyed by container name. Writes are atomic (temp-file + rename) and
9
+ serialized with ``fcntl.flock`` to make concurrent ``run`` from multiple
10
+ terminals safe.
11
+
12
+ ``resolve_target`` runs a lazy GC (``docker inspect`` each entry) before
13
+ addressing; entries whose container no longer exists are pruned so stale
14
+ records from deleted/crashed containers do not accumulate.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import tempfile
22
+ import time
23
+ from contextlib import contextmanager
24
+ from pathlib import Path
25
+ from typing import Any, Dict, Iterator, List, Optional
26
+
27
+ from aisc.domain.models import CliError, RuntimeErrorCode, RuntimeExitCode
28
+
29
+
30
+ _REGISTRY_FILE = "containers.json"
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Path resolution
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def _registry_path(root: Path) -> Path:
38
+ """Return path to containers.json inside the STATE DIRECTORY *root*.
39
+
40
+ Stage 7 wiring: *root* is the registry's state directory itself (the
41
+ data-root ``workspaces/<hash>/runtime`` dir, or the legacy
42
+ ``<workspace>/.aisc`` during transition) — callers resolve it via
43
+ ``application.data_root.workspace_state_dir``; this module never
44
+ concatenates workspace paths (01-cross-stage-contracts §1).
45
+ """
46
+ return root / _REGISTRY_FILE
47
+
48
+
49
+ def _state_dir(root: Path) -> Path:
50
+ """Return the state directory for locks/temp files (= *root* itself)."""
51
+ return root
52
+
53
+
54
+ def _resolve_root(root: Optional[Path], explicit_root: Optional[str] = None) -> Optional[Path]:
55
+ """Resolve the registry STATE DIR for the active workspace (Stage 7).
56
+
57
+ *root*/*explicit_root* may be a workspace path, a pre-resolved state
58
+ dir (legacy ``.aisc`` or data-root ``runtime``), or None (→ current
59
+ directory). State is per-workspace under the data root — the old
60
+ install-root fallback (repo/.aisc split-brain with run's workspace
61
+ registry) is gone; legacy state is adopted on first use.
62
+ Resolver failures propagate (fail closed, never write the workspace).
63
+ """
64
+ if root is not None:
65
+ base = Path(root)
66
+ elif explicit_root is not None:
67
+ base = Path(explicit_root).resolve()
68
+ if not base.is_dir():
69
+ return None
70
+ else:
71
+ base = Path.cwd()
72
+ if base.name == ".aisc" or (base / _REGISTRY_FILE).is_file():
73
+ return base # already a state dir
74
+ from aisc.application.data_root import workspace_state_dir
75
+
76
+ return workspace_state_dir(base)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Read / write
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def _read_registry(root: Path) -> Dict[str, Any]:
84
+ """Load the registry. Returns empty structure if file absent or corrupt."""
85
+ path = _registry_path(root)
86
+ if not path.is_file():
87
+ return {"default": "", "containers": {}}
88
+ try:
89
+ with open(path, "r", encoding="utf-8") as f:
90
+ data = json.load(f)
91
+ except (json.JSONDecodeError, OSError):
92
+ return {"default": "", "containers": {}}
93
+ if not isinstance(data, dict):
94
+ return {"default": "", "containers": {}}
95
+ data.setdefault("default", "")
96
+ data.setdefault("containers", {})
97
+ if not isinstance(data["containers"], dict):
98
+ data["containers"] = {}
99
+ return data
100
+
101
+
102
+ @contextmanager
103
+ def _registry_lock(root: Path, timeout: float = 10.0) -> Iterator[None]:
104
+ """Hold the registry lock across a complete read-modify-write cycle.
105
+
106
+ Uses fcntl.flock (POSIX) or msvcrt.locking (Windows).
107
+ Fail-closed: raises on lock acquisition failure.
108
+
109
+ Args:
110
+ root: AISC root directory
111
+ timeout: Lock timeout in seconds (default 10.0)
112
+
113
+ Raises:
114
+ CliError (STATE_LOCK_TIMEOUT): If lock cannot be acquired within timeout
115
+ OSError: On other lock-related errors
116
+
117
+ Yields:
118
+ None while lock is held
119
+ """
120
+ import sys
121
+ import time as time_module
122
+
123
+ state_dir = _state_dir(root)
124
+ state_dir.mkdir(parents=True, exist_ok=True)
125
+ lock_path = state_dir / ".containers.lock"
126
+ lock_fd = None
127
+ locked = False
128
+
129
+ try:
130
+ # Open lock file
131
+ lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
132
+
133
+ # Platform-specific locking with timeout
134
+ if sys.platform == "win32":
135
+ # Windows: msvcrt.locking with bounded retry
136
+ import msvcrt
137
+
138
+ start_time = time_module.time()
139
+ while True:
140
+ try:
141
+ # Try to lock 1 byte at offset 0
142
+ msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
143
+ locked = True
144
+ break
145
+ except OSError as e:
146
+ elapsed = time_module.time() - start_time
147
+ if elapsed >= timeout:
148
+ raise CliError(
149
+ message=f"Failed to acquire registry lock within {timeout}s",
150
+ exit_code=RuntimeExitCode.STATE_LOCK_TIMEOUT,
151
+ error_code=RuntimeErrorCode.STATE_LOCK_TIMEOUT,
152
+ ) from e
153
+ # Retry after short sleep
154
+ time_module.sleep(0.1)
155
+ else:
156
+ # POSIX: fcntl.flock with alarm-based timeout
157
+ import fcntl
158
+ import signal
159
+
160
+ def timeout_handler(signum, frame):
161
+ raise CliError(
162
+ message=f"Failed to acquire registry lock within {timeout}s",
163
+ exit_code=RuntimeExitCode.STATE_LOCK_TIMEOUT,
164
+ error_code=RuntimeErrorCode.STATE_LOCK_TIMEOUT,
165
+ )
166
+
167
+ # Only set alarm if we're in the main thread
168
+ # (signal.alarm only works in main thread)
169
+ try:
170
+ old_handler = signal.signal(signal.SIGALRM, timeout_handler)
171
+ # Use ceil to ensure timeout >= 1 second works
172
+ signal.alarm(int(timeout) + 1)
173
+ alarm_set = True
174
+ except ValueError:
175
+ # Not in main thread, fall back to blocking lock
176
+ alarm_set = False
177
+
178
+ try:
179
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
180
+ locked = True
181
+ finally:
182
+ if alarm_set:
183
+ signal.alarm(0)
184
+ signal.signal(signal.SIGALRM, old_handler)
185
+
186
+ yield
187
+
188
+ finally:
189
+ # Release lock
190
+ if locked and lock_fd is not None:
191
+ try:
192
+ if sys.platform == "win32":
193
+ import msvcrt
194
+ msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
195
+ else:
196
+ import fcntl
197
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
198
+ except OSError:
199
+ pass
200
+
201
+ # Close file descriptor
202
+ if lock_fd is not None:
203
+ try:
204
+ os.close(lock_fd)
205
+ except OSError:
206
+ pass
207
+
208
+
209
+ def _write_registry_unlocked(root: Path, data: Dict[str, Any]) -> None:
210
+ """Atomically replace the registry; caller is responsible for locking.
211
+
212
+ Atomicity still prevents readers from observing a partially written JSON
213
+ document while the separate lock serializes writers.
214
+ """
215
+ state_dir = _state_dir(root)
216
+ state_dir.mkdir(parents=True, exist_ok=True)
217
+ path = _registry_path(root)
218
+
219
+ fd, tmp_path = tempfile.mkstemp(prefix=".containers_", dir=str(state_dir), text=True)
220
+ try:
221
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
222
+ json.dump(data, f, indent=2, ensure_ascii=False)
223
+ f.flush()
224
+ os.fsync(f.fileno())
225
+ os.replace(tmp_path, str(path))
226
+ except Exception:
227
+ try:
228
+ os.unlink(tmp_path)
229
+ except OSError:
230
+ pass
231
+ raise
232
+
233
+
234
+ def _write_registry(root: Path, data: Dict[str, Any]) -> None:
235
+ """Atomically write a complete registry under the registry lock."""
236
+ with _registry_lock(root):
237
+ _write_registry_unlocked(root, data)
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Public API
242
+ # ---------------------------------------------------------------------------
243
+
244
+ def register(root: Path, name: str, meta: Dict[str, Any],
245
+ *, set_default: bool = True) -> None:
246
+ """Register a container and mark it as the default target.
247
+
248
+ Args:
249
+ root: AISC root directory.
250
+ name: Container name (unique per run).
251
+ meta: Metadata dict with keys:
252
+ - image: Docker image name
253
+ - workspace: Workspace path
254
+ - network: Network mode (direct/proxy)
255
+ - label: Optional user label
256
+ - runtime_id: Runtime ID (UUID v4 for Workbench runtimes)
257
+ - owner: Username who created this runtime
258
+ - scope: Scope mode (project/temporary)
259
+ - config_fingerprint: Config hash for idempotent retry
260
+ - container_id: Docker container ID (Workbench runtimes)
261
+ - workspace_key: sha256 of canonical workspace (Workbench runtimes)
262
+ - image_id: Content-addressed image ID at create time
263
+ (容器随镜像同步更新, KI-4 挂账 — empty on legacy records)
264
+ - lifecycle/retention/dependency_policy (runtime-lifecycle-ux
265
+ 02 §1): "" on legacy records — absent lifecycle is the legacy
266
+ marker reconcile treats as recyclable-after-verification;
267
+ never part of the config fingerprint.
268
+ - workbench_instance_id: creating Workbench instance (Stage 2+
269
+ callers); "" for CLI-created runtimes.
270
+ set_default: also mark the container as the default target. The
271
+ image_id heal path (start_runtime reusing a legacy record)
272
+ re-registers with False so an in-place metadata fix never
273
+ steals the default from another container.
274
+
275
+ Backward compatible: if old fields are missing, stores empty strings.
276
+ """
277
+ entry = {
278
+ "image": meta.get("image", ""),
279
+ "workspace": meta.get("workspace", ""),
280
+ "network": meta.get("network", ""),
281
+ "label": meta.get("label", ""),
282
+ "created_at": meta.get("created_at") or time.time(),
283
+ # New fields (v2.2.0+) - backward compatible
284
+ "runtime_id": meta.get("runtime_id", ""),
285
+ "owner": meta.get("owner", ""),
286
+ "scope": meta.get("scope", ""),
287
+ "config_fingerprint": meta.get("config_fingerprint", ""),
288
+ "container_id": meta.get("container_id", ""),
289
+ "workspace_key": meta.get("workspace_key", ""),
290
+ "image_id": meta.get("image_id", ""),
291
+ # svc-2 (web gateway): loopback host port of this runtime's gateway
292
+ # publish; 0/absent on legacy records (web access reports
293
+ # legacy_runtime). Runtime metadata only — never in the fingerprint.
294
+ "web_gateway_host_port": meta.get("web_gateway_host_port", 0),
295
+ # runtime-lifecycle-ux Stage 1 (02 §1): lifecycle metadata. Epoch
296
+ # float to match created_at (contract shows RFC3339; the registry's
297
+ # existing timestamp convention wins for on-disk consistency).
298
+ "lifecycle": meta.get("lifecycle", ""),
299
+ "retention": meta.get("retention", ""),
300
+ "dependency_policy": meta.get("dependency_policy", ""),
301
+ "workbench_instance_id": meta.get("workbench_instance_id", ""),
302
+ # runtime-lifecycle-ux 3a: which storage backend this runtime's
303
+ # persistent toolchain uses ("" on legacy/non-project records).
304
+ "toolchain_storage": meta.get("toolchain_storage", ""),
305
+ "last_state_change_at": meta.get("last_state_change_at") or time.time(),
306
+ }
307
+ with _registry_lock(root):
308
+ data = _read_registry(root)
309
+ data["containers"][name] = entry
310
+ if set_default:
311
+ data["default"] = name
312
+ _write_registry_unlocked(root, data)
313
+
314
+
315
+ def unregister(root: Path, name: str) -> None:
316
+ """Remove a container from the registry.
317
+
318
+ If the removed name was the default, repoint default to the most recently
319
+ created remaining entry (max created_at). Clears default when empty.
320
+ """
321
+ with _registry_lock(root):
322
+ data = _read_registry(root)
323
+ data["containers"].pop(name, None)
324
+ if data["default"] == name:
325
+ data["default"] = _pick_newest(data["containers"])
326
+ _write_registry_unlocked(root, data)
327
+
328
+
329
+ @contextmanager
330
+ def workspace_lock(root: Path, workspace_key: str, timeout: float = 10.0) -> Iterator[None]:
331
+ """Hold a per-workspace lock for project Runtime start.
332
+
333
+ Serializes ``registry/labels conflict check -> Docker create/ready ->
334
+ registry commit`` for one canonical workspace so two concurrent
335
+ ``project`` starts on the same workspace cannot both succeed.
336
+
337
+ Lock file: ``<state_dir>/workspace-locks/<workspace_key>.lock``.
338
+ Cross-platform and fail-closed, identical semantics to
339
+ :func:`_registry_lock`. Lock order is ``workspace lock -> registry lock``;
340
+ callers must not acquire the registry lock first.
341
+
342
+ Args:
343
+ root: Registry root (workspace root or ``.aisc`` dir).
344
+ workspace_key: ``sha256`` hex of the canonical workspace path.
345
+ timeout: Lock acquisition timeout in seconds.
346
+
347
+ Raises:
348
+ CliError (STATE_LOCK_TIMEOUT): if the lock cannot be acquired within *timeout*.
349
+ """
350
+ import sys
351
+ import time as time_module
352
+
353
+ state_dir = _state_dir(root)
354
+ locks_dir = state_dir / "workspace-locks"
355
+ locks_dir.mkdir(parents=True, exist_ok=True)
356
+ lock_path = locks_dir / f"{workspace_key}.lock"
357
+ lock_fd = None
358
+ locked = False
359
+
360
+ try:
361
+ lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
362
+
363
+ if sys.platform == "win32":
364
+ import msvcrt
365
+
366
+ start_time = time_module.time()
367
+ while True:
368
+ try:
369
+ msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
370
+ locked = True
371
+ break
372
+ except OSError as e:
373
+ if time_module.time() - start_time >= timeout:
374
+ raise CliError(
375
+ message=f"Failed to acquire workspace lock within {timeout}s",
376
+ exit_code=RuntimeExitCode.STATE_LOCK_TIMEOUT,
377
+ error_code=RuntimeErrorCode.STATE_LOCK_TIMEOUT,
378
+ ) from e
379
+ time_module.sleep(0.1)
380
+ else:
381
+ import fcntl
382
+ import signal
383
+
384
+ def timeout_handler(signum, frame):
385
+ raise CliError(
386
+ message=f"Failed to acquire workspace lock within {timeout}s",
387
+ exit_code=RuntimeExitCode.STATE_LOCK_TIMEOUT,
388
+ error_code=RuntimeErrorCode.STATE_LOCK_TIMEOUT,
389
+ )
390
+
391
+ # SIGALRM-based timeout only works in the main thread; a non-main
392
+ # caller (signal.signal raises ValueError) falls back to an
393
+ # unbounded blocking flock. Fine for the single-threaded aisc CLI;
394
+ # a future threaded caller would need a different timeout strategy.
395
+ alarm_set = False
396
+ try:
397
+ old_handler = signal.signal(signal.SIGALRM, timeout_handler)
398
+ signal.alarm(int(timeout) + 1)
399
+ alarm_set = True
400
+ except ValueError:
401
+ alarm_set = False
402
+
403
+ try:
404
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
405
+ locked = True
406
+ finally:
407
+ if alarm_set:
408
+ signal.alarm(0)
409
+ signal.signal(signal.SIGALRM, old_handler)
410
+
411
+ yield
412
+
413
+ finally:
414
+ if locked and lock_fd is not None:
415
+ try:
416
+ if sys.platform == "win32":
417
+ import msvcrt
418
+ msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
419
+ else:
420
+ import fcntl
421
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
422
+ except OSError:
423
+ pass
424
+ if lock_fd is not None:
425
+ try:
426
+ os.close(lock_fd)
427
+ except OSError:
428
+ pass
429
+
430
+
431
+ def find_by_runtime_id(root: Path, runtime_id: str) -> Optional[tuple]:
432
+ """Return ``(container_name, meta)`` for the registry entry matching
433
+ *runtime_id*, or ``None`` if not found. Reads under the registry lock.
434
+ """
435
+ with _registry_lock(root):
436
+ data = _read_registry(root)
437
+ for name, meta in data["containers"].items():
438
+ if isinstance(meta, dict) and meta.get("runtime_id", "") == runtime_id:
439
+ import copy
440
+ return name, copy.deepcopy(meta)
441
+ return None
442
+
443
+
444
+ def unregister_by_runtime_id(root: Path, runtime_id: str) -> Optional[str]:
445
+ """Remove the registry entry matching *runtime_id*.
446
+
447
+ Returns the removed container name, or ``None`` if no entry matched.
448
+ Repoints ``default`` when the removed entry was the default.
449
+ """
450
+ with _registry_lock(root):
451
+ data = _read_registry(root)
452
+ removed: Optional[str] = None
453
+ for name, meta in data["containers"].items():
454
+ if isinstance(meta, dict) and meta.get("runtime_id", "") == runtime_id:
455
+ removed = name
456
+ break
457
+ if removed is None:
458
+ return None
459
+ data["containers"].pop(removed, None)
460
+ if data["default"] == removed:
461
+ data["default"] = _pick_newest(data["containers"])
462
+ _write_registry_unlocked(root, data)
463
+ return removed
464
+
465
+
466
+ def list_containers(root: Path) -> Dict[str, Dict[str, Any]]:
467
+ """Return all registered container entries (name → meta).
468
+
469
+ Reads registry snapshot under lock to ensure consistency.
470
+ Returns a copy to prevent external mutation.
471
+ """
472
+ with _registry_lock(root):
473
+ data = _read_registry(root)
474
+ # Return a deep copy to prevent external mutation
475
+ import copy
476
+ return copy.deepcopy(data["containers"])
477
+
478
+
479
+ def list_containers_readonly(root: Path) -> Dict[str, Dict[str, Any]]:
480
+ """Return all registered container entries (name → meta) without side effects.
481
+
482
+ Read-only: does not acquire lock or create directories/files.
483
+ For preflight and other observational operations.
484
+ Returns empty dict if registry file doesn't exist.
485
+ Raises exception if registry exists but is corrupted/unreadable.
486
+ """
487
+ import copy
488
+ import json
489
+
490
+ registry_file = _registry_path(root)
491
+ if not registry_file.exists():
492
+ return {}
493
+
494
+ # File exists, so corruption/read errors must be raised
495
+ with open(registry_file, "r", encoding="utf-8") as f:
496
+ data = json.load(f)
497
+ if not isinstance(data, dict):
498
+ raise ValueError("Registry root is not a dict")
499
+ containers = data.get("containers", {})
500
+ if not isinstance(containers, dict):
501
+ raise ValueError("Registry containers field is not a dict")
502
+ return copy.deepcopy(containers)
503
+
504
+
505
+ def get_default(root: Path) -> str:
506
+ """Return the default container name, or empty string if none."""
507
+ with _registry_lock(root):
508
+ data = _read_registry(root)
509
+ return data.get("default", "")
510
+
511
+
512
+ def _pick_newest(containers: Dict[str, Any]) -> str:
513
+ """Return the name of the entry with the greatest created_at, or ''."""
514
+ if not containers:
515
+ return ""
516
+ best_name = ""
517
+ best_ts = -1.0
518
+ for nm, meta in containers.items():
519
+ ts = meta.get("created_at", 0) if isinstance(meta, dict) else 0
520
+ if ts >= best_ts:
521
+ best_ts = ts
522
+ best_name = nm
523
+ return best_name
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # GC — prune entries whose docker container no longer exists
528
+ # ---------------------------------------------------------------------------
529
+
530
+ def _container_exists(executor, name: str) -> Optional[bool]:
531
+ """Return True/False for container existence, or None if docker is
532
+ unreachable (GC skipped, do not block addressing)."""
533
+ fmt = '{{.State.Running}}'
534
+ proc = executor.run_captured(["inspect", "--format", fmt, name], timeout=10.0)
535
+ if proc.command_not_found or proc.timed_out:
536
+ return None
537
+ stderr_lower = (proc.stderr or "").lower()
538
+ if proc.exit_code != 0:
539
+ if any(kw in stderr_lower for kw in (
540
+ "no such object", "no such container", "not found",
541
+ )):
542
+ return False
543
+ # Daemon down / permission — skip GC
544
+ if any(kw in stderr_lower for kw in (
545
+ "cannot connect", "is the docker daemon running",
546
+ "connection refused", "error during connect", "permission denied",
547
+ )):
548
+ return None
549
+ return None
550
+ return True
551
+
552
+
553
+ def gc(root: Path, executor) -> List[str]:
554
+ """Prune registry entries whose container is gone. Returns pruned names.
555
+
556
+ Best-effort: if docker is unreachable, prunes nothing and returns [].
557
+
558
+ Pattern: lock→snapshot→unlock→inspect→relock→compare→prune per contract.
559
+ """
560
+ # Phase 1: lock → snapshot → unlock
561
+ with _registry_lock(root):
562
+ snapshot = _read_registry(root)
563
+ containers = snapshot.get("containers", {})
564
+ if not containers:
565
+ return []
566
+
567
+ # Phase 2: Docker inspect outside lock
568
+ missing: List[str] = []
569
+ for nm in list(containers):
570
+ exists = _container_exists(executor, nm)
571
+ if exists is False:
572
+ missing.append(nm)
573
+
574
+ if not missing:
575
+ return []
576
+
577
+ # Phase 3: relock → compare current entry → conditional prune
578
+ with _registry_lock(root):
579
+ current_data = _read_registry(root)
580
+ current_containers = current_data.get("containers", {})
581
+
582
+ pruned: List[str] = []
583
+ for nm in missing:
584
+ # Do not delete a same-name container re-registered while Docker
585
+ # checks were running.
586
+ if current_containers.get(nm) == containers.get(nm):
587
+ current_containers.pop(nm, None)
588
+ pruned.append(nm)
589
+
590
+ if pruned:
591
+ if current_data["default"] in pruned:
592
+ current_data["default"] = _pick_newest(current_containers)
593
+ _write_registry_unlocked(root, current_data)
594
+ return pruned
595
+
596
+
597
+ # ---------------------------------------------------------------------------
598
+ # Target resolution — replaces discover_container
599
+ # ---------------------------------------------------------------------------
600
+
601
+ def _format_candidates(containers: Dict[str, Dict[str, Any]]) -> str:
602
+ lines = []
603
+ for nm, meta in containers.items():
604
+ label = meta.get("label", "") or "-"
605
+ img = meta.get("image", "")
606
+ ws = meta.get("workspace", "")
607
+ lines.append(f" {nm} [{label}] {img} {ws}")
608
+ return "\n".join(lines)
609
+
610
+
611
+ def resolve_target(
612
+ root: Optional[Path] = None,
613
+ *,
614
+ name_override: Optional[str] = None,
615
+ label_override: Optional[str] = None,
616
+ executor=None,
617
+ explicit_root: Optional[str] = None,
618
+ ) -> str:
619
+ """Resolve which container to operate on.
620
+
621
+ Priority:
622
+ 1. ``name_override`` — validate existence via GC, return (or error+list)
623
+ 2. ``label_override`` — match by label; unique → return
624
+ 3. ``default`` pointer (after GC) — return if still present
625
+ 4. single registered container — return it
626
+ 5. multiple + no override — ``CliError`` listing candidates
627
+
628
+ Raises:
629
+ CliError: when no target can be resolved.
630
+ """
631
+ # r2 #2: --name needs no workspace at all — the name IS the address.
632
+ # Resolving a workspace from cwd misfires outside workspaces (from
633
+ # $HOME the data-root guard fires outright), blocking the very
634
+ # scenario --name exists for. Registry lookups degrade to "not
635
+ # registered" (the name is still accepted; cmd_status verifies it
636
+ # against docker).
637
+ resolved_root: Optional[Path] = None
638
+ try:
639
+ resolved_root = _resolve_root(root, explicit_root)
640
+ except CliError:
641
+ if not name_override:
642
+ raise
643
+ if resolved_root is None and not name_override:
644
+ raise CliError(
645
+ message=(
646
+ "No AISC root found and no container name/label given.\n"
647
+ " Pass --name NAME or --label LABEL, or run inside an AISC workspace."
648
+ ),
649
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
650
+ )
651
+
652
+ # Lazy GC before addressing (best-effort, executor may be None in dry paths)
653
+ if executor is not None and resolved_root is not None:
654
+ try:
655
+ gc(resolved_root, executor)
656
+ except Exception:
657
+ pass
658
+
659
+ containers = list_containers(resolved_root) if resolved_root is not None else {}
660
+
661
+ # 1. explicit name
662
+ if name_override:
663
+ if name_override in containers:
664
+ return name_override
665
+ # Not in registry — may still exist in docker (e.g. --name passed to a
666
+ # container run outside aisc, or registry was wiped). Accept it; caller's
667
+ # cmd_status will verify real existence via docker inspect.
668
+ return name_override
669
+
670
+ # 2. explicit label
671
+ if label_override:
672
+ matches = [nm for nm, m in containers.items()
673
+ if m.get("label", "") == label_override]
674
+ if len(matches) == 1:
675
+ return matches[0]
676
+ if not matches:
677
+ raise CliError(
678
+ message=(
679
+ f"No container with label '{label_override}'. Registered:\n"
680
+ f"{_format_candidates(containers) or ' (none)'}"
681
+ ),
682
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
683
+ )
684
+ raise CliError(
685
+ message=(
686
+ f"Multiple containers share label '{label_override}':\n"
687
+ + _format_candidates({nm: containers[nm] for nm in matches})
688
+ + "\nSpecify --name NAME to disambiguate."
689
+ ),
690
+ exit_code=1, error_code="AISC_ERR_MULTIPLE_CONTAINERS",
691
+ )
692
+
693
+ # 3. default pointer
694
+ default = get_default(resolved_root)
695
+ if default and default in containers:
696
+ return default
697
+
698
+ # 4. single container
699
+ if len(containers) == 1:
700
+ return next(iter(containers))
701
+
702
+ # 5. none / multiple
703
+ if not containers:
704
+ raise CliError(
705
+ message=(
706
+ "当前没有已注册容器——先用 aisc run <路径> 激活,"
707
+ "或用 --name <名> / --label <标签> 指定目标。"
708
+ ),
709
+ exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
710
+ )
711
+ raise CliError(
712
+ message=(
713
+ f"注册了多个容器({len(containers)} 个),请用 --name <名> "
714
+ f"或 --label <标签> 指定:\n"
715
+ + _format_candidates(containers)
716
+ ),
717
+ exit_code=1, error_code="AISC_ERR_MULTIPLE_CONTAINERS",
718
+ )