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,1244 @@
1
+ """DockerGateway adapter (Stage 4, DG-01..DG-08).
2
+
3
+ Evolves the CLI-only ``RealDockerExecutor`` into a backend-selecting gateway
4
+ while keeping ``DockerExecutor`` as a compatibility alias for at least one
5
+ release (D4-02). Backend choice (``auto|sdk|cli``) lives entirely inside this
6
+ module — application/domain never branches on it (D4-06).
7
+
8
+ Layer diagram (per ``stage-4-docker-gateway/02-domain-contract.md``):
9
+
10
+ application/domain
11
+ ↓ DockerGateway (Protocol)
12
+ AutoGateway → SdkGateway | CliGateway
13
+ ↓
14
+ Docker Engine
15
+
16
+ Backends
17
+ --------
18
+ - ``SdkGateway``: docker-py for query (preflight/inspect/list) and lifecycle
19
+ (start/stop/remove/wait). Interactive exec stays SDK-first (D4-03, reuses the
20
+ transport helpers from ``docker_.RealDockerExecutor.open_interactive``).
21
+ - ``CliGateway``: wraps ``RealDockerExecutor`` (argv-only, no ``shell=True``);
22
+ kept as the fallback until the equivalence matrix is complete (D4-08).
23
+ - ``AutoGateway``: selects by capability/feature flag; never leaks the choice.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import socket
31
+ import sys
32
+ import threading
33
+ import time
34
+ import uuid
35
+ from dataclasses import dataclass
36
+ from typing import Any, Callable, List, Optional, Protocol, runtime_checkable
37
+
38
+ from aisc.adapters.docker_ import _poll_resize_step
39
+ from aisc.domain.gateway import (
40
+ BuildResult,
41
+ ContainerInspectResult,
42
+ ContainerListResult,
43
+ ContainerSummary,
44
+ GatewayOperation,
45
+ GatewayResult,
46
+ ImageInspectGatewayResult,
47
+ InteractiveResult,
48
+ LifecycleResult,
49
+ PreflightResult,
50
+ )
51
+ from aisc.domain.models import (
52
+ BuildPlan,
53
+ DockerPreflightResult,
54
+ ImageInspectResult,
55
+ ImageInspectStatus,
56
+ ProcessResult,
57
+ )
58
+
59
+
60
+ def _poll_env(name: str, default: float, lo: float, hi: float) -> float:
61
+ """PERF P2 (D-13) tunable poll cadence — env override clamped to a sane
62
+ range (also keeps tests fast without sleeping production intervals)."""
63
+ try:
64
+ v = float(os.environ.get(name, "") or default)
65
+ except ValueError:
66
+ return default
67
+ return min(hi, max(lo, v))
68
+
69
+
70
+ def _resize_poll_interval() -> float:
71
+ """Resize-file poll cadence: default 0.25s (was 0.1s)."""
72
+ return _poll_env("AISC_RESIZE_POLL", 0.25, 0.05, 1.0)
73
+
74
+
75
+ def _exec_floor_interval() -> float:
76
+ """While-stream-open re-inspect floor: default 5s."""
77
+ return _poll_env("AISC_EXEC_FLOOR", 5.0, 0.05, 60.0)
78
+
79
+
80
+ def _exec_settle_interval() -> float:
81
+ """EOF-but-Running corner settle poll: default 1s."""
82
+ return _poll_env("AISC_EXEC_SETTLE", 1.0, 0.05, 10.0)
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Backend identity (adapter-internal; never leaks to application code)
87
+ # ---------------------------------------------------------------------------
88
+
89
+ BackendName = str # "auto" | "sdk" | "cli"
90
+
91
+ # Stable Docker error codes (D4-07). Prefixed to avoid colliding with AISC_*.
92
+ class DockerErrorCode:
93
+ DAEMON_UNREACHABLE = "DOCKER_ERR_DAEMON_UNREACHABLE"
94
+ CLI_NOT_FOUND = "DOCKER_ERR_CLI_NOT_FOUND"
95
+ PERMISSION_DENIED = "DOCKER_ERR_PERMISSION_DENIED"
96
+ NOT_FOUND = "DOCKER_ERR_NOT_FOUND"
97
+ TIMEOUT = "DOCKER_ERR_TIMEOUT"
98
+ CONFLICT = "DOCKER_ERR_CONFLICT"
99
+ UNKNOWN = "DOCKER_ERR_UNKNOWN"
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Gateway protocol — the single injectable abstraction (DG-01)
104
+ # ---------------------------------------------------------------------------
105
+
106
+ @runtime_checkable
107
+ class DockerGateway(Protocol):
108
+ """Structured gateway over the Docker Engine.
109
+
110
+ Every method returns a :class:`GatewayResult` subclass carrying
111
+ ``operation_id``, ``backend``, exit code, duration, stable error and
112
+ cleanup status. stdout/stderr are only populated on explicit capture
113
+ request and are bounded.
114
+ """
115
+
116
+ @property
117
+ def backend(self) -> str:
118
+ """Which backend actually runs (sdk | cli | auto)."""
119
+ ...
120
+
121
+ def preflight(self) -> PreflightResult:
122
+ """Daemon reachability + docker availability."""
123
+ ...
124
+
125
+ def inspect_image(self, image_name: str) -> ImageInspectGatewayResult:
126
+ """Structured local-image inspection."""
127
+ ...
128
+
129
+ def list_containers(self, all: bool = False) -> ContainerListResult:
130
+ """List containers with stable per-row summaries."""
131
+ ...
132
+
133
+ def inspect_container(self, container: str) -> ContainerInspectResult:
134
+ """Inspect one container."""
135
+ ...
136
+
137
+ def start_container(self, container: str) -> LifecycleResult:
138
+ """Start a container."""
139
+ ...
140
+
141
+ def stop_container(self, container: str, timeout: int = 10) -> LifecycleResult:
142
+ """Stop a container with a grace timeout."""
143
+ ...
144
+
145
+ def remove_container(self, container: str, force: bool = False) -> LifecycleResult:
146
+ """Remove a container."""
147
+ ...
148
+
149
+ def wait_container(self, container: str, timeout: Optional[float] = None) -> LifecycleResult:
150
+ """Wait for a container to exit; returns its final state."""
151
+ ...
152
+
153
+ def open_interactive(
154
+ self,
155
+ container: str,
156
+ argv: List[str],
157
+ env: Optional[Dict[str, str]] = None,
158
+ ) -> InteractiveResult:
159
+ """Open an interactive exec session (SDK-first, resizable)."""
160
+ ...
161
+
162
+ def build_image(
163
+ self,
164
+ plan: BuildPlan,
165
+ on_event: Optional[Callable[[str, str], None]] = None,
166
+ ) -> BuildResult:
167
+ """Build an image; events stream to *on_event* when provided."""
168
+ ...
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Operation envelope helper
173
+ # ---------------------------------------------------------------------------
174
+
175
+ def _new_operation(backend: str, **kw) -> GatewayOperation:
176
+ # Allow callers to override duration_ms without a duplicate-kwarg clash.
177
+ return GatewayOperation(
178
+ operation_id=uuid.uuid4().hex[:16],
179
+ backend=backend,
180
+ **kw,
181
+ )
182
+
183
+
184
+ def _elapsed(start: float) -> int:
185
+ return int((time.monotonic() - start) * 1000)
186
+
187
+
188
+ def _default_client():
189
+ """2.1.9 hotfix r3 (#61): docker.from_env() with DOCKER_HOST unset reads
190
+ the docker CLI context meta.json WITHOUT an encoding — on zh-CN Windows
191
+ that GBK-decodes Docker Desktop's UTF-8 context file and raises a plain
192
+ Exception ("corrupted meta file"), not a DockerException. Fall back to
193
+ the platform DEFAULT endpoint (explicit base_url never reads meta.json).
194
+ Mirror of RealDockerExecutor._client_from_env_safe."""
195
+ import docker
196
+
197
+ try:
198
+ return docker.from_env()
199
+ except Exception: # noqa: BLE001 — context meta unreadable
200
+ import os as _os
201
+
202
+ if _os.name == "nt":
203
+ return docker.DockerClient(base_url="npipe:////./pipe/docker_engine")
204
+ return docker.DockerClient(base_url="unix:///var/run/docker.sock")
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # 2.1.10 R2 (D-8): live exec TTY as a stream handle — serve PTY transport
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ def _sock_recv(sock: Any, size: int) -> bytes:
213
+ """Raw read from a docker-py exec socket (recv | read | os.read)."""
214
+ if hasattr(sock, "recv"):
215
+ return sock.recv(size)
216
+ if hasattr(sock, "read"):
217
+ return sock.read(size)
218
+ return os.read(sock.fileno(), size)
219
+
220
+
221
+ def _sock_send_all(sock: Any, data: bytes) -> None:
222
+ """Send every byte (sendall | _sock.sendall | write | os.write)."""
223
+ if hasattr(sock, "sendall"):
224
+ sock.sendall(data)
225
+ return
226
+ raw = getattr(sock, "_sock", None)
227
+ if raw is not None and hasattr(raw, "sendall"):
228
+ raw.sendall(data)
229
+ return
230
+ view = memoryview(data)
231
+ while view:
232
+ if hasattr(sock, "write") and getattr(sock, "writable", lambda: False)():
233
+ sent = sock.write(view)
234
+ else:
235
+ sent = os.write(sock.fileno(), view)
236
+ if sent is None:
237
+ raise OSError("socket write would block")
238
+ if sent <= 0:
239
+ raise OSError("socket write failed")
240
+ view = view[sent:]
241
+
242
+
243
+ def _sock_shutdown_write(sock: Any) -> None:
244
+ """Half-close the write side on stdin EOF (shutdown | _sock.shutdown)."""
245
+ raw = getattr(sock, "_sock", None)
246
+ targets = [raw, sock] if raw is not None else [sock]
247
+ for target in targets:
248
+ if hasattr(target, "shutdown"):
249
+ try:
250
+ target.shutdown(socket.SHUT_WR)
251
+ return
252
+ except OSError:
253
+ pass
254
+ try:
255
+ sock.close()
256
+ except OSError:
257
+ pass
258
+
259
+
260
+ class InteractiveStreamHandle:
261
+ """One live docker exec TTY: raw byte bidirectional plane + resize.
262
+
263
+ Produced by ``_open_sdk_pty_stream`` (SDK only — the CLI backend cannot
264
+ resize an exec pty, which is why G-02 exists). The pump thread invokes
265
+ ``on_output`` from its own thread; everything else is called from the
266
+ owning thread. Exit detection is the P2 EOF-driven scheme verbatim.
267
+ """
268
+
269
+ def __init__(self, api: Any, exec_id: str, sock: Any,
270
+ on_output: Optional[Callable[[bytes], None]]) -> None:
271
+ import threading as _threading
272
+
273
+ self._api = api
274
+ self._exec_id = exec_id
275
+ self._sock = sock
276
+ self._on_output = on_output or (lambda chunk: None)
277
+ self._eof = _threading.Event()
278
+ self._stopped = _threading.Event()
279
+ self._error: Optional[Exception] = None
280
+ self._exit_code: Optional[int] = None
281
+ self._pump = _threading.Thread(target=self._pump_loop, daemon=True)
282
+ self._pump.start()
283
+
284
+ # -- byte plane ----------------------------------------------------------
285
+
286
+ def _pump_loop(self) -> None:
287
+ try:
288
+ while not self._stopped.is_set():
289
+ chunk = _sock_recv(self._sock, 65536)
290
+ if not chunk:
291
+ break
292
+ self._on_output(chunk)
293
+ except Exception as exc: # noqa: BLE001 — surfaced via .error
294
+ self._error = exc
295
+ finally:
296
+ self._eof.set()
297
+
298
+ def write(self, data: bytes) -> None:
299
+ """stdin → socket. Raises OSError-family on a dead stream."""
300
+ _sock_send_all(self._sock, data)
301
+
302
+ def resize(self, size: tuple) -> None:
303
+ """(cols, rows) → exec_resize."""
304
+ self._api.exec_resize(self._exec_id, height=size[1], width=size[0])
305
+
306
+ def close_stdin(self) -> None:
307
+ _sock_shutdown_write(self._sock)
308
+
309
+ def kill(self) -> None:
310
+ """Detach: close the socket (pump hits EOF and settles) — the exec
311
+ process itself keeps running in the container, the same semantics as
312
+ killing the pipe-mode sidecar process. ``wait_exit`` observes the
313
+ detach and returns instead of polling a Running exec forever."""
314
+ self._stopped.set()
315
+ try:
316
+ self._sock.close()
317
+ except OSError:
318
+ pass
319
+
320
+ # -- exit ---------------------------------------------------------------
321
+
322
+ @property
323
+ def error(self) -> Optional[Exception]:
324
+ return self._error
325
+
326
+ @property
327
+ def exit_code(self) -> Optional[int]:
328
+ return self._exit_code
329
+
330
+ def wait_exit(self) -> int:
331
+ """Block until the exec settles; returns its exit code. The P2
332
+ EOF-driven scheme verbatim: EOF → ONE inspect; no EOF → floor
333
+ re-inspect; EOF-but-Running → settle loop. Unbounded, like the
334
+ open_interactive contract (call from a dedicated thread)."""
335
+ import docker
336
+ import requests
337
+
338
+ failures = 0
339
+
340
+ def inspect_info() -> dict:
341
+ """exec_inspect with the #61 transient-failure tolerance."""
342
+ nonlocal failures
343
+ while True:
344
+ try:
345
+ info = self._api.exec_inspect(self._exec_id)
346
+ failures = 0
347
+ return info
348
+ except (docker.errors.APIError, requests.RequestException, OSError):
349
+ failures += 1
350
+ if failures >= 3:
351
+ raise
352
+ time.sleep(0.5)
353
+
354
+ floor = _exec_floor_interval()
355
+ settle = _exec_settle_interval()
356
+ info: Optional[dict] = None
357
+ while info is None:
358
+ if self._stopped.is_set():
359
+ # Client-detached (kill): the exec keeps running in the
360
+ # container; report the detach as an unknown exit instead of
361
+ # settling forever on a Running inspect.
362
+ self._exit_code = -1
363
+ return self._exit_code
364
+ if not self._eof.is_set():
365
+ info = inspect_info()
366
+ if info.get("Running"):
367
+ info = None
368
+ self._eof.wait(timeout=floor)
369
+ else:
370
+ info = inspect_info()
371
+ if info.get("Running"):
372
+ info = None
373
+ time.sleep(settle)
374
+ self._exit_code = int(info.get("ExitCode", 0))
375
+ return self._exit_code
376
+
377
+ def join_pump(self, timeout: float = 5.0) -> None:
378
+ self._pump.join(timeout=timeout)
379
+
380
+
381
+ def _open_sdk_pty_stream(
382
+ client: Any,
383
+ container: str,
384
+ argv: List[str],
385
+ env: Optional[Dict[str, str]],
386
+ on_output: Optional[Callable[[bytes], None]],
387
+ initial_size: Optional[tuple],
388
+ ) -> InteractiveStreamHandle:
389
+ """exec_create → exec_start(socket) → InteractiveStreamHandle. Raises
390
+ the same DockerException family open_interactive's create/start legs
391
+ map (the caller decides the error shape)."""
392
+ exec_kwargs: Dict[str, Any] = {"tty": True, "stdin": True}
393
+ if env:
394
+ exec_kwargs["environment"] = dict(env)
395
+ exec_id = client.api.exec_create(container, list(argv), **exec_kwargs)["Id"]
396
+ sock = client.api.exec_start(exec_id, socket=True, tty=True)
397
+ handle = InteractiveStreamHandle(client.api, exec_id, sock, on_output)
398
+ if initial_size:
399
+ try:
400
+ handle.resize(initial_size)
401
+ except Exception: # noqa: BLE001 — best-effort initial size, as ever
402
+ pass
403
+ return handle
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # SDK backend — docker-py (query + lifecycle)
408
+ # ---------------------------------------------------------------------------
409
+
410
+ @dataclass
411
+ class SdkGateway:
412
+ """docker-py backed gateway (D4-03/D4-04, query+lifecycle first).
413
+
414
+ ``client`` may be injected for tests (Fake/recording); when ``None`` the
415
+ gateway lazily creates ``docker.from_env()``.
416
+ """
417
+
418
+ client: object = None # docker.DockerClient (duck-typed to keep SDK optional)
419
+ timeout: float = 10.0
420
+ # 2.1.9 hotfix r3: the default_factory CALLS _default_client() at
421
+ # construction — eager docker.from_env() (a zh-CN GBK landmine) and a
422
+ # client instance where _client() expects a callable. A plain function
423
+ # default keeps the field lazy AND callable.
424
+ _client_factory: Callable[[], object] = _default_client
425
+
426
+ @property
427
+ def backend(self) -> str:
428
+ return "sdk"
429
+
430
+ def _client(self):
431
+ if self.client is None:
432
+ self.client = self._client_factory()
433
+ return self.client
434
+
435
+ # -- helpers ----------------------------------------------------------
436
+
437
+ def _op(self, **kw) -> GatewayOperation:
438
+ return _new_operation("sdk", **kw)
439
+
440
+ # -- query ------------------------------------------------------------
441
+
442
+ def preflight(self) -> PreflightResult:
443
+ import docker # lazy: keep the module importable without the SDK
444
+ start = time.monotonic()
445
+ try:
446
+ client = self._client()
447
+ version = client.version() # raises if daemon unreachable
448
+ return PreflightResult(
449
+ operation=_new_operation(
450
+ "sdk", exit_code=0, duration_ms=_elapsed(start), cleanup_status="ok",
451
+ ),
452
+ available=True,
453
+ reason="ok",
454
+ docker_version=str(version.get("Version", "")),
455
+ engine_ok=True,
456
+ )
457
+ except docker.errors.DockerException as exc:
458
+ return PreflightResult(
459
+ operation=_new_operation(
460
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
461
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
462
+ error_message=str(exc), cleanup_status="ok",
463
+ ),
464
+ available=False,
465
+ reason="daemon_unreachable",
466
+ )
467
+ except Exception as exc: # noqa: BLE001
468
+ return PreflightResult(
469
+ operation=_new_operation(
470
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
471
+ error_code=DockerErrorCode.UNKNOWN,
472
+ error_message=str(exc), cleanup_status="ok",
473
+ ),
474
+ available=False,
475
+ reason="daemon_unreachable",
476
+ )
477
+
478
+ def inspect_image(self, image_name: str) -> ImageInspectGatewayResult:
479
+ import docker
480
+ start = time.monotonic()
481
+ try:
482
+ client = self._client()
483
+ client.images.get(image_name)
484
+ return ImageInspectGatewayResult(
485
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
486
+ status=ImageInspectStatus.EXISTS,
487
+ image=image_name,
488
+ message="",
489
+ )
490
+ except docker.errors.ImageNotFound:
491
+ return ImageInspectGatewayResult(
492
+ operation=_new_operation("sdk", exit_code=5, duration_ms=_elapsed(start)),
493
+ status=ImageInspectStatus.MISSING,
494
+ image=image_name,
495
+ message="image not found locally",
496
+ )
497
+ except docker.errors.DockerException as exc:
498
+ return ImageInspectGatewayResult(
499
+ operation=_new_operation(
500
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
501
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
502
+ error_message=str(exc),
503
+ ),
504
+ status=ImageInspectStatus.DOCKER_UNAVAILABLE,
505
+ image=image_name,
506
+ message=str(exc),
507
+ )
508
+
509
+ def list_containers(self, all: bool = False) -> ContainerListResult:
510
+ import docker
511
+ start = time.monotonic()
512
+ try:
513
+ client = self._client()
514
+ rows = client.containers.list(all=all)
515
+ summaries = [
516
+ ContainerSummary(
517
+ id=c.id[:12],
518
+ name=(c.name or ""),
519
+ image=(c.image.tags[0] if getattr(c, "image", None) and getattr(c.image, "tags", None) else ""),
520
+ state=(c.status or ""),
521
+ status=(c.attrs.get("State", {}).get("Status", "") or (c.status or "")),
522
+ labels=(c.attrs.get("Config", {}).get("Labels", {}) or {}),
523
+ # Full sha256 image ID ships with the list attrs — no
524
+ # extra API round-trip (A0: structured image ID).
525
+ image_id=(c.attrs.get("Image") or ""),
526
+ )
527
+ for c in rows
528
+ ]
529
+ return ContainerListResult(
530
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
531
+ containers=summaries,
532
+ )
533
+ except docker.errors.DockerException as exc:
534
+ return ContainerListResult(
535
+ operation=_new_operation(
536
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
537
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
538
+ error_message=str(exc),
539
+ ),
540
+ )
541
+
542
+ def inspect_container(self, container: str) -> ContainerInspectResult:
543
+ import docker
544
+ start = time.monotonic()
545
+ try:
546
+ client = self._client()
547
+ c = client.containers.get(container)
548
+ attrs = c.attrs
549
+ state = attrs.get("State", {}) or {}
550
+ config = attrs.get("Config", {}) or {}
551
+ return ContainerInspectResult(
552
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
553
+ container_id=c.id,
554
+ name=(c.name or ""),
555
+ state=state.get("Status", ""),
556
+ image=attrs.get("Config", {}).get("Image", "") or "",
557
+ labels=config.get("Labels", {}) or {},
558
+ config=config,
559
+ # `.Image` in inspect attrs = content-addressed ID, distinct
560
+ # from the Config.Image REF above (A0: structured image ID).
561
+ image_id=(attrs.get("Image") or ""),
562
+ )
563
+ except docker.errors.NotFound:
564
+ return ContainerInspectResult(
565
+ operation=_new_operation(
566
+ "sdk", exit_code=1, duration_ms=_elapsed(start),
567
+ error_code=DockerErrorCode.NOT_FOUND,
568
+ error_message=f"container {container} not found",
569
+ ),
570
+ )
571
+ except docker.errors.DockerException as exc:
572
+ return ContainerInspectResult(
573
+ operation=_new_operation(
574
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
575
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
576
+ error_message=str(exc),
577
+ ),
578
+ )
579
+
580
+ # -- lifecycle --------------------------------------------------------
581
+
582
+ def start_container(self, container: str) -> LifecycleResult:
583
+ import docker
584
+ start = time.monotonic()
585
+ try:
586
+ client = self._client()
587
+ c = client.containers.get(container)
588
+ c.start()
589
+ return LifecycleResult(
590
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
591
+ target=container,
592
+ observed_state="running",
593
+ container_id=c.id,
594
+ )
595
+ except docker.errors.NotFound:
596
+ return LifecycleResult(
597
+ operation=_new_operation(
598
+ "sdk", exit_code=1, duration_ms=_elapsed(start),
599
+ error_code=DockerErrorCode.NOT_FOUND,
600
+ error_message=f"container {container} not found",
601
+ ),
602
+ target=container,
603
+ )
604
+ except docker.errors.DockerException as exc:
605
+ return LifecycleResult(
606
+ operation=_new_operation(
607
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
608
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
609
+ error_message=str(exc),
610
+ ),
611
+ target=container,
612
+ )
613
+
614
+ def stop_container(self, container: str, timeout: int = 10) -> LifecycleResult:
615
+ import docker
616
+ start = time.monotonic()
617
+ try:
618
+ client = self._client()
619
+ c = client.containers.get(container)
620
+ c.stop(timeout=timeout)
621
+ return LifecycleResult(
622
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
623
+ target=container,
624
+ observed_state="stopped",
625
+ container_id=c.id,
626
+ )
627
+ except docker.errors.NotFound:
628
+ return LifecycleResult(
629
+ operation=_new_operation(
630
+ "sdk", exit_code=1, duration_ms=_elapsed(start),
631
+ error_code=DockerErrorCode.NOT_FOUND,
632
+ error_message=f"container {container} not found",
633
+ ),
634
+ target=container,
635
+ )
636
+ except docker.errors.DockerException as exc:
637
+ return LifecycleResult(
638
+ operation=_new_operation(
639
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
640
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
641
+ error_message=str(exc),
642
+ ),
643
+ target=container,
644
+ )
645
+
646
+ def remove_container(self, container: str, force: bool = False) -> LifecycleResult:
647
+ import docker
648
+ start = time.monotonic()
649
+ try:
650
+ client = self._client()
651
+ c = client.containers.get(container)
652
+ c.remove(force=force)
653
+ return LifecycleResult(
654
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
655
+ target=container,
656
+ observed_state="removed",
657
+ container_id=c.id,
658
+ )
659
+ except docker.errors.NotFound:
660
+ # Already absent is a successful remove (idempotent).
661
+ return LifecycleResult(
662
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
663
+ target=container,
664
+ observed_state="removed",
665
+ )
666
+ except docker.errors.DockerException as exc:
667
+ return LifecycleResult(
668
+ operation=_new_operation(
669
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
670
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
671
+ error_message=str(exc),
672
+ ),
673
+ target=container,
674
+ )
675
+
676
+ def wait_container(self, container: str, timeout: Optional[float] = None) -> LifecycleResult:
677
+ import docker
678
+ start = time.monotonic()
679
+ try:
680
+ client = self._client()
681
+ c = client.containers.get(container)
682
+ try:
683
+ c.wait(timeout=timeout)
684
+ except Exception as exc: # noqa: BLE001
685
+ # docker-py surfaces wait timeout as requests.ReadTimeout (not a
686
+ # DockerException); classify by class name so the deadline path
687
+ # returns a stable TIMEOUT result instead of escaping.
688
+ if "ReadTimeout" in type(exc).__name__ or "Timeout" in type(exc).__name__:
689
+ return LifecycleResult(
690
+ operation=_new_operation(
691
+ "sdk", exit_code=1, duration_ms=_elapsed(start),
692
+ error_code=DockerErrorCode.TIMEOUT,
693
+ error_message=f"wait timed out after {timeout}s",
694
+ timed_out=True,
695
+ ),
696
+ target=container,
697
+ )
698
+ if isinstance(exc, docker.errors.DockerException):
699
+ raise
700
+ return LifecycleResult(
701
+ operation=_new_operation(
702
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
703
+ error_code=DockerErrorCode.UNKNOWN,
704
+ error_message=str(exc),
705
+ ),
706
+ target=container,
707
+ )
708
+ try:
709
+ c.reload()
710
+ except docker.errors.NotFound:
711
+ # Container removed between wait and reload: report exited.
712
+ return LifecycleResult(
713
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
714
+ target=container,
715
+ observed_state="exited",
716
+ )
717
+ return LifecycleResult(
718
+ operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
719
+ target=container,
720
+ observed_state=((c.attrs.get("State", {}) or {}).get("Status", "exited")),
721
+ container_id=c.id,
722
+ )
723
+ except docker.errors.NotFound:
724
+ return LifecycleResult(
725
+ operation=_new_operation(
726
+ "sdk", exit_code=1, duration_ms=_elapsed(start),
727
+ error_code=DockerErrorCode.NOT_FOUND,
728
+ error_message=f"container {container} not found",
729
+ ),
730
+ target=container,
731
+ )
732
+ except docker.errors.DockerException as exc:
733
+ return LifecycleResult(
734
+ operation=_new_operation(
735
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
736
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
737
+ error_message=str(exc),
738
+ ),
739
+ target=container,
740
+ )
741
+
742
+ # -- interactive (SDK-first; D4-03 owns create/start/resize/stream/reap) --
743
+
744
+ def open_pty_stream(
745
+ self,
746
+ container: str,
747
+ argv: List[str],
748
+ env: Optional[Dict[str, str]] = None,
749
+ on_output: Optional[Callable[[bytes], None]] = None,
750
+ initial_size: Optional[tuple] = None,
751
+ ) -> "InteractiveStreamHandle":
752
+ """2.1.10 R2 (D-8): a LIVE exec TTY as a stream handle — the serve
753
+ PTY transport. Same lifecycle as ``open_interactive`` (create →
754
+ socket → resize via API) but the byte plane is a callback instead of
755
+ fd 0/1, resize is a method call instead of the resize file, and the
756
+ caller decides when to stop. ``open_interactive`` is now assembled
757
+ from this handle (zero behavior change there).
758
+
759
+ ``on_output`` is invoked from the pump thread; it must be cheap and
760
+ thread-safe. ``initial_size`` (cols, rows) replaces the resize file's
761
+ spawn-time read.
762
+ """
763
+ return _open_sdk_pty_stream(
764
+ self._client(), container, argv, env, on_output, initial_size
765
+ )
766
+
767
+ def open_interactive(
768
+ self,
769
+ container: str,
770
+ argv: List[str],
771
+ env: Optional[Dict[str, str]] = None,
772
+ ) -> InteractiveResult:
773
+ """Interactive TTY session via the Docker SDK, using the injected client.
774
+
775
+ Lifecycle (D4-03/A-DG04-1): ``exec_create`` → ``exec_start`` (socket) →
776
+ terminal-size watcher forwards ``exec_resize`` → raw stdout/stdin stream →
777
+ ``exec_inspect`` poll for exit → join/reap all threads. No resource is
778
+ left behind on error: the stop event is always set and threads joined.
779
+
780
+ v2.1.7 S6: *env* rides the exec environment only (never the image or a
781
+ profile) — the bash tutorial ``help`` function enters sessions this way.
782
+ """
783
+ import docker
784
+ import requests # 2.1.9 hotfix (#61): transport failures are not DockerExceptions
785
+
786
+ start = time.monotonic()
787
+ try:
788
+ client = self._client()
789
+ exec_kwargs: Dict[str, Any] = {"tty": True, "stdin": True}
790
+ if env:
791
+ exec_kwargs["environment"] = dict(env)
792
+ exec_id = client.api.exec_create(container, list(argv), **exec_kwargs)["Id"]
793
+ except docker.errors.NotFound:
794
+ return InteractiveResult(
795
+ operation=_new_operation(
796
+ "sdk", exit_code=1, duration_ms=_elapsed(start),
797
+ error_code=DockerErrorCode.NOT_FOUND,
798
+ error_message=f"container {container} not found",
799
+ ),
800
+ session_id="",
801
+ )
802
+ except docker.errors.DockerException as exc:
803
+ return InteractiveResult(
804
+ operation=_new_operation(
805
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
806
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
807
+ error_message=str(exc),
808
+ ),
809
+ session_id="",
810
+ )
811
+ except (requests.RequestException, OSError) as exc:
812
+ return InteractiveResult(
813
+ operation=_new_operation(
814
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
815
+ error_code=DockerErrorCode.DAEMON_UNREACHABLE,
816
+ error_message=f"exec create transport failure: {exc}",
817
+ ),
818
+ session_id="",
819
+ )
820
+
821
+ try:
822
+ sock = client.api.exec_start(exec_id, socket=True, tty=True)
823
+ except (docker.errors.DockerException, requests.RequestException, OSError) as exc:
824
+ return InteractiveResult(
825
+ operation=_new_operation(
826
+ "sdk", exit_code=3, duration_ms=_elapsed(start),
827
+ error_code=DockerErrorCode.UNKNOWN,
828
+ error_message=f"exec start failed: {exc}",
829
+ ),
830
+ session_id=exec_id,
831
+ )
832
+
833
+ stop = threading.Event()
834
+ errors: List[Exception] = []
835
+
836
+ # Initial resize from the resize file (set by the pty supervisor before
837
+ # spawn; AISC_RESIZE_FILE = "<cols> <rows>\n").
838
+ resize_file = os.environ.get("AISC_RESIZE_FILE")
839
+ initial_size: Optional[tuple] = None
840
+ if resize_file:
841
+ try:
842
+ content = open(resize_file).read().strip().split()
843
+ if len(content) == 2:
844
+ initial_size = (int(content[0]), int(content[1]))
845
+ except Exception: # noqa: BLE001
846
+ pass
847
+
848
+ # 2.1.10 R2 (D-8): the live stream is ONE handle now — the raw byte
849
+ # plane, the EOF-driven exit scheme (PERF P2) and the #61 inspect
850
+ # tolerance all live in InteractiveStreamHandle; this method only
851
+ # adapts fd 0/1 and the resize file around it. (The AISC_EXEC_POLL=
852
+ # legacy escape hatch retired here — P2 said "kept one version" and
853
+ # two have passed.)
854
+ def on_output(chunk: bytes) -> None:
855
+ os.write(sys.stdout.fileno(), chunk)
856
+
857
+ handle = InteractiveStreamHandle(client.api, exec_id, sock, on_output)
858
+ if initial_size:
859
+ try:
860
+ handle.resize(initial_size)
861
+ except Exception: # noqa: BLE001 — best-effort initial size, as ever
862
+ pass
863
+
864
+ def forward() -> None:
865
+ """stdin → socket; on EOF close the write side."""
866
+ try:
867
+ while True:
868
+ chunk = os.read(sys.stdin.fileno(), 4096)
869
+ if not chunk:
870
+ break
871
+ handle.write(chunk)
872
+ except Exception as exc: # noqa: BLE001
873
+ errors.append(exc)
874
+ finally:
875
+ handle.close_stdin()
876
+
877
+ def watch_resize() -> None:
878
+ """Poll the resize file; forward changes to exec_resize."""
879
+ if not resize_file:
880
+ return
881
+ # Local copy + module-level step helper: immune to the closure
882
+ # trap that previously dropped every post-initial resize (B-05).
883
+ # PERF P2: 0.25s cadence (was 0.1s) — inside the 200-300ms resize
884
+ # perception threshold, pane dragging stays smooth; ~10 file
885
+ # reads/sec per session was idle-CPU tax on weak machines.
886
+ interval = _resize_poll_interval()
887
+ last = initial_size
888
+ while not stop.is_set():
889
+ last = _poll_resize_step(
890
+ resize_file,
891
+ last,
892
+ handle.resize,
893
+ )
894
+ stop.wait(interval)
895
+
896
+ t_fwd = threading.Thread(target=forward, daemon=True)
897
+ t_resize = threading.Thread(target=watch_resize, daemon=True)
898
+ t_fwd.start()
899
+ t_resize.start()
900
+
901
+ exit_code = -1
902
+ waited = False
903
+ try:
904
+ exit_code = handle.wait_exit()
905
+ waited = True
906
+ except (docker.errors.APIError, requests.RequestException, OSError) as exc:
907
+ errors.append(exc)
908
+ finally:
909
+ stop.set()
910
+ handle.join_pump(timeout=5)
911
+ t_fwd.join(timeout=5)
912
+ t_resize.join(timeout=5)
913
+
914
+ if handle.error is not None:
915
+ errors.append(handle.error)
916
+ if errors:
917
+ return InteractiveResult(
918
+ operation=_new_operation(
919
+ "sdk", exit_code=-1, duration_ms=_elapsed(start),
920
+ error_code=DockerErrorCode.UNKNOWN,
921
+ error_message=f"exec stream error: {errors[0]}",
922
+ ),
923
+ session_id=exec_id,
924
+ )
925
+ return InteractiveResult(
926
+ operation=_new_operation("sdk", exit_code=exit_code, duration_ms=_elapsed(start)),
927
+ session_id=exec_id,
928
+ waited=waited,
929
+ )
930
+
931
+ def build_image(
932
+ self,
933
+ plan: BuildPlan,
934
+ on_event: Optional[Callable[[str, str], None]] = None,
935
+ ) -> BuildResult:
936
+ from aisc.adapters.docker_ import RealDockerExecutor
937
+ executor = RealDockerExecutor()
938
+ if on_event is not None:
939
+ result = executor.run_streaming_captured(
940
+ plan.docker_argv,
941
+ on_chunk=on_event,
942
+ )
943
+ else:
944
+ result = executor.run_captured(plan.docker_argv)
945
+ return BuildResult(
946
+ operation=_new_operation("sdk", exit_code=result.exit_code, duration_ms=0),
947
+ image_ref=plan.tag,
948
+ stdout=result.stdout,
949
+ stderr=result.stderr,
950
+ )
951
+
952
+
953
+ # ---------------------------------------------------------------------------
954
+ # CLI backend — wraps RealDockerExecutor (argv-only fallback, D4-04/D4-08)
955
+ # ---------------------------------------------------------------------------
956
+
957
+ @dataclass
958
+ class CliGateway:
959
+ """CLI-backed gateway: delegates to the existing ``RealDockerExecutor``.
960
+
961
+ Kept for the equivalence/regression window; never removed until the SDK/CLI
962
+ matrix and cross-platform evidence are complete (D4-08).
963
+ """
964
+
965
+ executor: object = None # RealDockerExecutor (duck-typed)
966
+
967
+ def _exec(self):
968
+ if self.executor is None:
969
+ from aisc.adapters.docker_ import RealDockerExecutor
970
+ self.executor = RealDockerExecutor()
971
+ return self.executor
972
+
973
+ @property
974
+ def backend(self) -> str:
975
+ return "cli"
976
+
977
+ def _op(self, result: ProcessResult, **kw) -> GatewayOperation:
978
+ return _new_operation(
979
+ "cli",
980
+ exit_code=result.exit_code,
981
+ timed_out=result.timed_out,
982
+ **kw,
983
+ )
984
+
985
+ def preflight(self) -> PreflightResult:
986
+ start = time.monotonic()
987
+ r: DockerPreflightResult = self._exec().preflight()
988
+ return PreflightResult(
989
+ operation=_new_operation(
990
+ "cli", exit_code=r.exit_code, duration_ms=_elapsed(start),
991
+ error_code=r.error_code or "",
992
+ ),
993
+ available=r.available,
994
+ reason=r.reason,
995
+ docker_path=r.docker_path,
996
+ )
997
+
998
+ def inspect_image(self, image_name: str) -> ImageInspectGatewayResult:
999
+ start = time.monotonic()
1000
+ r: ImageInspectResult = self._exec().inspect_image(image_name)
1001
+ return ImageInspectGatewayResult(
1002
+ operation=_new_operation("cli", exit_code=_map_inspect_exit(r.status), duration_ms=_elapsed(start)),
1003
+ status=r.status,
1004
+ image=r.image,
1005
+ message=r.message,
1006
+ )
1007
+
1008
+ def list_containers(self, all: bool = False) -> ContainerListResult:
1009
+ start = time.monotonic()
1010
+ r: ProcessResult = self._exec().list_containers(all=all)
1011
+ result = ContainerListResult(
1012
+ operation=self._op(r, duration_ms=_elapsed(start)),
1013
+ stdout=r.stdout,
1014
+ stderr=r.stderr,
1015
+ )
1016
+ if r.exit_code != 0:
1017
+ return result
1018
+ # RealDockerExecutor lists with
1019
+ # `--format "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}"` — machine
1020
+ # format, stable fields. `docker ps` cannot carry labels or the
1021
+ # content-addressed image ID: rows leave labels={} / image_id="" and
1022
+ # callers inspect the interesting candidates individually (A0).
1023
+ for line in (r.stdout or "").splitlines():
1024
+ parts = line.split("\t")
1025
+ if len(parts) < 4:
1026
+ continue
1027
+ result.containers.append(
1028
+ ContainerSummary(
1029
+ id=parts[0].strip(),
1030
+ name=parts[1].strip(),
1031
+ image=parts[2].strip(),
1032
+ status=parts[3].strip(),
1033
+ )
1034
+ )
1035
+ return result
1036
+
1037
+ def inspect_container(self, container: str) -> ContainerInspectResult:
1038
+ start = time.monotonic()
1039
+ r: ProcessResult = self._exec().inspect_container(container)
1040
+ if r.exit_code != 0:
1041
+ return ContainerInspectResult(
1042
+ operation=self._op(r, duration_ms=_elapsed(start)),
1043
+ stdout=r.stdout,
1044
+ stderr=r.stderr,
1045
+ )
1046
+ # `docker inspect` emits stable machine JSON (never human text).
1047
+ try:
1048
+ attrs = json.loads(r.stdout or "[]")
1049
+ attrs = attrs[0] if isinstance(attrs, list) and attrs else {}
1050
+ except ValueError:
1051
+ attrs = {}
1052
+ if not isinstance(attrs, dict):
1053
+ attrs = {}
1054
+ config = attrs.get("Config", {}) or {}
1055
+ return ContainerInspectResult(
1056
+ operation=self._op(r, duration_ms=_elapsed(start)),
1057
+ stdout=r.stdout,
1058
+ stderr=r.stderr,
1059
+ container_id=attrs.get("Id", "") or "",
1060
+ name=(attrs.get("Name", "") or "").lstrip("/"),
1061
+ state=(attrs.get("State", {}) or {}).get("Status", "") or "",
1062
+ image=config.get("Image", "") or "",
1063
+ labels=config.get("Labels", {}) or {},
1064
+ config=config,
1065
+ image_id=attrs.get("Image", "") or "",
1066
+ )
1067
+
1068
+ def start_container(self, container: str) -> LifecycleResult:
1069
+ # CLI executor exposes run_captured; start = `docker start <name>`.
1070
+ return self._lifecycle_via_argv(["start", container], target=container, state="running")
1071
+
1072
+ def stop_container(self, container: str, timeout: int = 10) -> LifecycleResult:
1073
+ return self._lifecycle_via_argv(
1074
+ ["stop", "-t", str(timeout), container], target=container, state="stopped",
1075
+ )
1076
+
1077
+ def remove_container(self, container: str, force: bool = False) -> LifecycleResult:
1078
+ argv = ["rm"] + (["-f"] if force else []) + [container]
1079
+ return self._lifecycle_via_argv(argv, target=container, state="removed")
1080
+
1081
+ def wait_container(self, container: str, timeout: Optional[float] = None) -> LifecycleResult:
1082
+ return self._lifecycle_via_argv(["wait", container], target=container, state="exited")
1083
+
1084
+ def _lifecycle_via_argv(
1085
+ self, argv: List[str], *, target: str, state: str,
1086
+ ) -> LifecycleResult:
1087
+ start = time.monotonic()
1088
+ r: ProcessResult = self._exec().run_captured(argv)
1089
+ return LifecycleResult(
1090
+ operation=_new_operation(
1091
+ "cli", exit_code=r.exit_code, duration_ms=_elapsed(start),
1092
+ timed_out=r.timed_out,
1093
+ ),
1094
+ target=target,
1095
+ observed_state=state if r.exit_code == 0 else "",
1096
+ )
1097
+
1098
+ def open_interactive(self, container: str, argv: List[str]) -> InteractiveResult:
1099
+ start = time.monotonic()
1100
+ r: ProcessResult = self._exec().open_interactive(container, argv)
1101
+ return InteractiveResult(
1102
+ operation=_new_operation("cli", exit_code=r.exit_code, duration_ms=_elapsed(start)),
1103
+ )
1104
+
1105
+ def build_image(
1106
+ self,
1107
+ plan: BuildPlan,
1108
+ on_event: Optional[Callable[[str, str], None]] = None,
1109
+ ) -> BuildResult:
1110
+ start = time.monotonic()
1111
+ executor = self._exec()
1112
+ if on_event is not None:
1113
+ r: ProcessResult = executor.run_streaming_captured(plan.docker_argv, on_chunk=on_event)
1114
+ else:
1115
+ r: ProcessResult = executor.run_captured(plan.docker_argv)
1116
+ return BuildResult(
1117
+ operation=_new_operation("cli", exit_code=r.exit_code, duration_ms=_elapsed(start)),
1118
+ image_ref=plan.tag,
1119
+ stdout=r.stdout,
1120
+ stderr=r.stderr,
1121
+ )
1122
+
1123
+
1124
+ def _map_inspect_exit(status: str) -> int:
1125
+ if status == ImageInspectStatus.EXISTS:
1126
+ return 0
1127
+ if status == ImageInspectStatus.MISSING:
1128
+ return 5
1129
+ if status == ImageInspectStatus.PERMISSION_DENIED:
1130
+ return 9
1131
+ if status == ImageInspectStatus.TIMEOUT:
1132
+ return 1
1133
+ return 3 # docker_unavailable / error
1134
+
1135
+
1136
+ # ---------------------------------------------------------------------------
1137
+ # Auto backend — capability-based selection (D4-06)
1138
+ # ---------------------------------------------------------------------------
1139
+
1140
+ @dataclass
1141
+ class AutoGateway:
1142
+ """Selects sdk vs cli by capability.
1143
+
1144
+ The choice is a pure function of environment capability (SDK importable,
1145
+ daemon reachable); it never reflects a caller-supplied preference. The
1146
+ resolved backend is exposed on every operation result for diagnostics.
1147
+ """
1148
+
1149
+ _sdk: Optional[SdkGateway] = None
1150
+ _cli: Optional[CliGateway] = None
1151
+ prefer_sdk: bool = True # SDK is the default when available (D4-03/D4-04)
1152
+
1153
+ @property
1154
+ def backend(self) -> str:
1155
+ return "auto"
1156
+
1157
+ def _resolve(self) -> DockerGateway:
1158
+ if self.prefer_sdk:
1159
+ try:
1160
+ import docker # noqa: F401
1161
+ if self._sdk is None:
1162
+ self._sdk = SdkGateway()
1163
+ return self._sdk
1164
+ except ImportError:
1165
+ pass
1166
+ if self._cli is None:
1167
+ self._cli = CliGateway()
1168
+ return self._cli
1169
+
1170
+ def preflight(self) -> PreflightResult:
1171
+ return self._resolve().preflight()
1172
+
1173
+ def inspect_image(self, image_name: str) -> ImageInspectGatewayResult:
1174
+ return self._resolve().inspect_image(image_name)
1175
+
1176
+ def list_containers(self, all: bool = False) -> ContainerListResult:
1177
+ return self._resolve().list_containers(all=all)
1178
+
1179
+ def inspect_container(self, container: str) -> ContainerInspectResult:
1180
+ return self._resolve().inspect_container(container)
1181
+
1182
+ def start_container(self, container: str) -> LifecycleResult:
1183
+ return self._resolve().start_container(container)
1184
+
1185
+ def stop_container(self, container: str, timeout: int = 10) -> LifecycleResult:
1186
+ return self._resolve().stop_container(container, timeout=timeout)
1187
+
1188
+ def remove_container(self, container: str, force: bool = False) -> LifecycleResult:
1189
+ return self._resolve().remove_container(container, force=force)
1190
+
1191
+ def wait_container(self, container: str, timeout: Optional[float] = None) -> LifecycleResult:
1192
+ return self._resolve().wait_container(container, timeout=timeout)
1193
+
1194
+ def open_interactive(self, container: str, argv: List[str]) -> InteractiveResult:
1195
+ return self._resolve().open_interactive(container, argv)
1196
+
1197
+ def open_pty_stream(self, container, argv, env=None, on_output=None,
1198
+ initial_size=None) -> InteractiveStreamHandle:
1199
+ """2.1.10 R2: PTY streams are SDK-only (exec_resize needs the API —
1200
+ the reason G-02 exists). Resolve straight to the SDK backend; a
1201
+ missing SDK is a hard error for this transport, never a CLI
1202
+ fallback."""
1203
+ try:
1204
+ import docker # noqa: F401
1205
+ except ImportError as exc:
1206
+ raise NotImplementedError(
1207
+ "pty streams require the Docker SDK backend"
1208
+ ) from exc
1209
+ backend = self._resolve()
1210
+ return backend.open_pty_stream(container, argv, env=env,
1211
+ on_output=on_output,
1212
+ initial_size=initial_size)
1213
+
1214
+ def build_image(
1215
+ self,
1216
+ plan: BuildPlan,
1217
+ on_event: Optional[Callable[[str, str], None]] = None,
1218
+ ) -> BuildResult:
1219
+ return self._resolve().build_image(plan, on_event=on_event)
1220
+
1221
+
1222
+ # ---------------------------------------------------------------------------
1223
+ # Factory + compatibility alias
1224
+ # ---------------------------------------------------------------------------
1225
+
1226
+ def create_docker_gateway(
1227
+ backend: BackendName = "auto",
1228
+ client: object = None,
1229
+ ) -> DockerGateway:
1230
+ """Return a gateway for *backend* (auto | sdk | cli).
1231
+
1232
+ ``client`` injects a docker-py client (tests/recording); CLI backend accepts
1233
+ an executor for the same purpose. The backend string is never interpreted
1234
+ outside this module.
1235
+ """
1236
+ if backend == "sdk":
1237
+ return SdkGateway(client=client)
1238
+ if backend == "cli":
1239
+ return CliGateway(executor=client)
1240
+ return AutoGateway(_sdk=(SdkGateway(client=client) if client is not None else None))
1241
+
1242
+
1243
+ # D4-02: compatibility alias — existing callers of `DockerExecutor` keep working.
1244
+ DockerExecutor = DockerGateway