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,821 @@
1
+ """Doctor checks — host-only diagnosis.
2
+
3
+ Each check is a simple callable that returns a ``CheckResult``.
4
+ Checks are ordered and the report determines the final exit code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import stat
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Callable, List, Optional
14
+
15
+ from aisc.domain.models import CheckResult, CheckStatus, DoctorReport
16
+ from aisc.adapters.system import ProcessRunner, RealProcessRunner
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Timeout constants (seconds)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ DOCKER_TIMEOUT = 8.0
24
+ GIT_TIMEOUT = 5.0
25
+ COMPOSE_TIMEOUT = 5.0
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Check implementations
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def _check_docker_cli(
33
+ docker_path: str,
34
+ runner: ProcessRunner,
35
+ ) -> CheckResult:
36
+ """Check 1: Docker CLI discovery / version."""
37
+ result = runner.run([docker_path, "--version"], timeout=DOCKER_TIMEOUT)
38
+ if result.command_not_found:
39
+ return CheckResult(
40
+ name="docker-cli",
41
+ status=CheckStatus.FAIL,
42
+ message="Docker CLI not found",
43
+ detail="docker command not available on PATH",
44
+ hint="Install Docker: https://docs.docker.com/get-docker/",
45
+ )
46
+ if result.timed_out:
47
+ return CheckResult(
48
+ name="docker-cli",
49
+ status=CheckStatus.FAIL,
50
+ message="Docker CLI timed out",
51
+ detail="docker --version did not respond",
52
+ )
53
+ if result.exit_code != 0:
54
+ return CheckResult(
55
+ name="docker-cli",
56
+ status=CheckStatus.FAIL,
57
+ message="Docker CLI returned an error",
58
+ detail=result.stderr.strip() or result.stdout.strip(),
59
+ )
60
+ version_line = (
61
+ result.stdout.strip().splitlines()[0]
62
+ if result.stdout.strip()
63
+ else "(unknown)"
64
+ )
65
+ return CheckResult(
66
+ name="docker-cli",
67
+ status=CheckStatus.PASS,
68
+ message=version_line,
69
+ )
70
+
71
+
72
+ def _check_docker_daemon(
73
+ docker_path: str,
74
+ runner: ProcessRunner,
75
+ docker_cli_available: bool,
76
+ ) -> CheckResult:
77
+ """Check 2: Docker daemon (``docker info``)."""
78
+ if not docker_cli_available:
79
+ return CheckResult(
80
+ name="docker-daemon",
81
+ status=CheckStatus.SKIP,
82
+ message="Docker CLI not available",
83
+ )
84
+ result = runner.run([docker_path, "info"], timeout=DOCKER_TIMEOUT)
85
+ if result.timed_out:
86
+ return CheckResult(
87
+ name="docker-daemon",
88
+ status=CheckStatus.FAIL,
89
+ message="Docker daemon check timed out",
90
+ hint="Ensure the Docker daemon is running",
91
+ )
92
+ if result.exit_code != 0:
93
+ return CheckResult(
94
+ name="docker-daemon",
95
+ status=CheckStatus.FAIL,
96
+ message="Docker daemon not running or unreachable",
97
+ detail=result.stderr.strip() or result.stdout.strip(),
98
+ hint="Ensure the Docker daemon is running",
99
+ )
100
+ return CheckResult(
101
+ name="docker-daemon",
102
+ status=CheckStatus.PASS,
103
+ message="Docker daemon is running",
104
+ )
105
+
106
+
107
+ def _check_docker_permission(
108
+ docker_path: str,
109
+ runner: ProcessRunner,
110
+ docker_cli_available: bool,
111
+ docker_daemon_failed: bool,
112
+ ) -> CheckResult:
113
+ """Check 3: Docker permission (``docker ps``)."""
114
+ if not docker_cli_available:
115
+ return CheckResult(
116
+ name="docker-permission",
117
+ status=CheckStatus.SKIP,
118
+ message="Docker CLI not available",
119
+ )
120
+ if docker_daemon_failed:
121
+ return CheckResult(
122
+ name="docker-permission",
123
+ status=CheckStatus.SKIP,
124
+ message="Docker daemon not available",
125
+ )
126
+ result = runner.run([docker_path, "ps"], timeout=DOCKER_TIMEOUT)
127
+ if result.timed_out:
128
+ return CheckResult(
129
+ name="docker-permission",
130
+ status=CheckStatus.FAIL,
131
+ message="Docker permission check timed out",
132
+ )
133
+ if result.exit_code != 0:
134
+ return CheckResult(
135
+ name="docker-permission",
136
+ status=CheckStatus.FAIL,
137
+ message="Permission denied — cannot access Docker daemon",
138
+ detail=result.stderr.strip() or result.stdout.strip(),
139
+ hint="Ensure your user has access to Docker (e.g. docker group membership)",
140
+ )
141
+ return CheckResult(
142
+ name="docker-permission",
143
+ status=CheckStatus.PASS,
144
+ message="Docker permission OK",
145
+ )
146
+
147
+
148
+ def _check_docker_buildx(
149
+ docker_path: str,
150
+ runner: ProcessRunner,
151
+ docker_cli_available: bool,
152
+ docker_daemon_failed: bool,
153
+ ) -> CheckResult:
154
+ """Check 4: Docker Buildx (``docker buildx version``)."""
155
+ if not docker_cli_available:
156
+ return CheckResult(
157
+ name="docker-buildx",
158
+ status=CheckStatus.SKIP,
159
+ message="Docker CLI not available",
160
+ )
161
+ if docker_daemon_failed:
162
+ return CheckResult(
163
+ name="docker-buildx",
164
+ status=CheckStatus.SKIP,
165
+ message="Docker daemon not available",
166
+ )
167
+ result = runner.run([docker_path, "buildx", "version"], timeout=DOCKER_TIMEOUT)
168
+ if result.timed_out:
169
+ return CheckResult(
170
+ name="docker-buildx",
171
+ status=CheckStatus.WARN,
172
+ message="Docker Buildx check timed out",
173
+ )
174
+ if result.exit_code != 0:
175
+ return CheckResult(
176
+ name="docker-buildx",
177
+ status=CheckStatus.WARN,
178
+ message="Docker Buildx not available",
179
+ detail=result.stderr.strip() or result.stdout.strip(),
180
+ hint="Install buildx: https://docs.docker.com/buildx/",
181
+ )
182
+ version_line = (
183
+ result.stdout.strip().splitlines()[0]
184
+ if result.stdout.strip()
185
+ else "(unknown)"
186
+ )
187
+ return CheckResult(
188
+ name="docker-buildx",
189
+ status=CheckStatus.PASS,
190
+ message=version_line,
191
+ )
192
+
193
+
194
+ def _check_tun_device() -> CheckResult:
195
+ """Check 5: Linux ``/dev/net/tun`` (character device)."""
196
+ if sys.platform != "linux":
197
+ return CheckResult(
198
+ name="tun-device",
199
+ status=CheckStatus.SKIP,
200
+ message="Not Linux — /dev/net/tun check skipped",
201
+ )
202
+ tun = Path("/dev/net/tun")
203
+ try:
204
+ st = tun.stat()
205
+ if stat.S_ISCHR(st.st_mode):
206
+ return CheckResult(
207
+ name="tun-device",
208
+ status=CheckStatus.PASS,
209
+ message="/dev/net/tun exists and is a character device",
210
+ )
211
+ return CheckResult(
212
+ name="tun-device",
213
+ status=CheckStatus.WARN,
214
+ message="/dev/net/tun exists but is not a character device",
215
+ )
216
+ except FileNotFoundError:
217
+ return CheckResult(
218
+ name="tun-device",
219
+ status=CheckStatus.WARN,
220
+ message="/dev/net/tun not found",
221
+ detail="TUN device required for transparent proxy mode",
222
+ )
223
+ except OSError:
224
+ return CheckResult(
225
+ name="tun-device",
226
+ status=CheckStatus.WARN,
227
+ message="/dev/net/tun inaccessible",
228
+ )
229
+
230
+
231
+ def _check_aisc_root(
232
+ root: Optional[Path],
233
+ root_error: Optional[str] = None,
234
+ ) -> CheckResult:
235
+ """Check 6: AISC root / bundle discovery.
236
+
237
+ *root_error* is the original error message when an explicit or env
238
+ source was invalid. Passed through verbatim.
239
+ """
240
+ if root_error is not None:
241
+ return CheckResult(
242
+ name="aisc-root",
243
+ status=CheckStatus.FAIL,
244
+ message=root_error,
245
+ )
246
+ if root is not None:
247
+ return CheckResult(
248
+ name="aisc-root",
249
+ status=CheckStatus.PASS,
250
+ message=f"Found at {root}",
251
+ )
252
+ return CheckResult(
253
+ name="aisc-root",
254
+ status=CheckStatus.WARN,
255
+ message="AISC root not found (no repo in parent directories)",
256
+ detail="Some checks require a repo root to read VERSION / container files",
257
+ )
258
+
259
+
260
+ def _check_bundle_store(root: Optional[Path]) -> CheckResult:
261
+ """Check 6b: the fetched-bundle store (pip installs' build resources).
262
+
263
+ PASS when the ACTIVE root is a repo/frozen bundle (the store is
264
+ irrelevant); PASS when it resolves from ``bundles/<ver>/``; WARN when a
265
+ store exists but nothing is compatible; WARN on pip forms with no store
266
+ at all (build needs ``aisc bundle fetch``)."""
267
+ import sys as _sys
268
+
269
+ from aisc import __version__
270
+
271
+ pip_form = (
272
+ not getattr(_sys, "frozen", False)
273
+ and __file__ is not None
274
+ and "site-packages" in str(Path(__file__).resolve())
275
+ )
276
+ try:
277
+ from aisc.application.data_root import shared_root
278
+
279
+ dr = shared_root()
280
+ except Exception:
281
+ dr = None
282
+ if dr is not None:
283
+ from aisc.application.resources import find_data_root_bundles
284
+
285
+ candidates, skipped = find_data_root_bundles(dr)
286
+ if candidates:
287
+ return CheckResult(
288
+ name="aisc-bundle",
289
+ status=CheckStatus.PASS,
290
+ message=f"bundle store: {candidates[0]}",
291
+ )
292
+ if skipped:
293
+ return CheckResult(
294
+ name="aisc-bundle",
295
+ status=CheckStatus.WARN,
296
+ message="bundle store present but no compatible bundle",
297
+ detail="; ".join(skipped[:3]),
298
+ )
299
+ if root is not None:
300
+ return CheckResult(
301
+ name="aisc-bundle",
302
+ status=CheckStatus.PASS,
303
+ message=f"root resolves without the bundle store ({root})",
304
+ )
305
+ if pip_form:
306
+ return CheckResult(
307
+ name="aisc-bundle",
308
+ status=CheckStatus.WARN,
309
+ message="pip install without build resources",
310
+ detail="run `aisc bundle fetch` to download this version's bundle "
311
+ "(build/docker-rebuild need it)",
312
+ )
313
+ return CheckResult(
314
+ name="aisc-bundle",
315
+ status=CheckStatus.WARN,
316
+ message="no bundle store and no repo root",
317
+ )
318
+
319
+
320
+ def _check_channel_confusion() -> CheckResult:
321
+ """Check 6c: multiple `aisc` hits on PATH -> WARN with the list.
322
+
323
+ Only lists paths + file info by default — running every hit's
324
+ `--version` is `--verbose` territory (never execute unknown binaries
325
+ unprompted)."""
326
+ import shutil as _shutil
327
+
328
+ hits: list = []
329
+ for d in os.environ.get("PATH", "").split(os.pathsep):
330
+ if not d:
331
+ continue
332
+ for cand in (Path(d) / "aisc", Path(d) / "aisc.exe"):
333
+ try:
334
+ if cand.is_file() and cand.stat().st_size > 0:
335
+ hits.append(str(cand))
336
+ except OSError:
337
+ continue
338
+ if len(hits) > 1:
339
+ return CheckResult(
340
+ name="channel-confusion",
341
+ status=CheckStatus.WARN,
342
+ message=f"{len(hits)} aisc executables on PATH",
343
+ detail="; ".join(hits[:4]) + (" …" if len(hits) > 4 else ""),
344
+ )
345
+ if len(hits) == 1:
346
+ return CheckResult(name="channel-confusion", status=CheckStatus.PASS,
347
+ message=hits[0])
348
+ return CheckResult(name="channel-confusion", status=CheckStatus.WARN,
349
+ message="no aisc executable found on PATH")
350
+
351
+
352
+ def _check_platform_support() -> CheckResult:
353
+ """Check 6d: py3-none-any wheel installs anywhere; payloads are
354
+ linux-amd64. Unsupported combos get a WARN (no install-time hook
355
+ exists for pure wheels — guide 3.5.5)."""
356
+ import platform as _platform
357
+
358
+ combo = f"{_platform.system().lower()}-{_platform.machine().lower()}"
359
+ supported = {"windows-amd64", "linux-x86_64", "darwin-arm64"}
360
+ if combo in supported:
361
+ return CheckResult(name="platform-support", status=CheckStatus.PASS,
362
+ message=combo)
363
+ return CheckResult(
364
+ name="platform-support",
365
+ status=CheckStatus.WARN,
366
+ message=f"unsupported combination {combo}",
367
+ detail="official artifacts are windows-x86_64 / linux-x86_64 / "
368
+ "macos-arm64; builds may be extremely slow or unstable here",
369
+ )
370
+
371
+
372
+ def _check_root_files(root: Optional[Path]) -> List[CheckResult]:
373
+ """Check 7: Verify key root files exist."""
374
+ if root is None:
375
+ return [
376
+ CheckResult(
377
+ name="aisc-root-files",
378
+ status=CheckStatus.SKIP,
379
+ message="No AISC root found — file checks skipped",
380
+ )
381
+ ]
382
+
383
+ required = {
384
+ # A2 dual-shape: repo checkouts carry it at src/aisc/VERSION.
385
+ "VERSION": ("VERSION", "src/aisc/VERSION"),
386
+ "container/Dockerfile": ("container/Dockerfile",),
387
+ "config/versions.env": ("config/versions.env",),
388
+ }
389
+ results: List[CheckResult] = []
390
+ for label, rels in required.items():
391
+ p = next((root / rel for rel in rels if (root / rel).is_file()), None)
392
+ if p is not None:
393
+ results.append(
394
+ CheckResult(
395
+ name=f"root-file:{label}",
396
+ status=CheckStatus.PASS,
397
+ message=f"{rels[0]} exists",
398
+ )
399
+ )
400
+ else:
401
+ results.append(
402
+ CheckResult(
403
+ name=f"root-file:{label}",
404
+ status=CheckStatus.FAIL,
405
+ message=f"{rels[0]} not found",
406
+ )
407
+ )
408
+ return results
409
+
410
+
411
+ def _check_git(
412
+ git_path: str,
413
+ runner: ProcessRunner,
414
+ ) -> CheckResult:
415
+ """Check 8: Git discovery / version."""
416
+ result = runner.run([git_path, "--version"], timeout=GIT_TIMEOUT)
417
+ if result.command_not_found or result.exit_code != 0:
418
+ return CheckResult(
419
+ name="git",
420
+ status=CheckStatus.WARN,
421
+ message="Git not available",
422
+ )
423
+ version_line = (
424
+ result.stdout.strip().splitlines()[0]
425
+ if result.stdout.strip()
426
+ else "(unknown)"
427
+ )
428
+ return CheckResult(
429
+ name="git",
430
+ status=CheckStatus.PASS,
431
+ message=version_line,
432
+ )
433
+
434
+
435
+ def _check_docker_compose(
436
+ docker_path: Optional[str],
437
+ runner: ProcessRunner,
438
+ docker_cli_available: bool,
439
+ ) -> CheckResult:
440
+ """Check 9: Docker Compose (``docker compose version``)."""
441
+ if not docker_cli_available or docker_path is None:
442
+ return CheckResult(
443
+ name="docker-compose",
444
+ status=CheckStatus.SKIP,
445
+ message="Docker CLI not available",
446
+ )
447
+ result = runner.run(
448
+ [docker_path, "compose", "version"], timeout=COMPOSE_TIMEOUT,
449
+ )
450
+ if result.command_not_found:
451
+ return CheckResult(
452
+ name="docker-compose",
453
+ status=CheckStatus.WARN,
454
+ message="Docker Compose subcommand not available",
455
+ detail="docker command exists but 'compose' subcommand not found",
456
+ hint="Ensure Docker Compose plugin is installed: https://docs.docker.com/compose/install/",
457
+ )
458
+ if result.timed_out:
459
+ return CheckResult(
460
+ name="docker-compose",
461
+ status=CheckStatus.WARN,
462
+ message="Docker Compose check timed out",
463
+ )
464
+ if result.exit_code != 0:
465
+ detail = result.stderr.strip() or result.stdout.strip()
466
+ lowered = detail.lower()
467
+ if "unknown command" in lowered or "not a docker command" in lowered:
468
+ return CheckResult(
469
+ name="docker-compose",
470
+ status=CheckStatus.WARN,
471
+ message="Docker Compose subcommand not available",
472
+ detail=detail,
473
+ hint="Install the Docker Compose plugin: https://docs.docker.com/compose/install/",
474
+ )
475
+ return CheckResult(
476
+ name="docker-compose",
477
+ status=CheckStatus.WARN,
478
+ message="Docker Compose returned an error",
479
+ detail=detail,
480
+ hint="Run 'docker compose version' manually and check the Docker Compose plugin installation.",
481
+ )
482
+ version_line = (
483
+ result.stdout.strip().splitlines()[0]
484
+ if result.stdout.strip()
485
+ else "(unknown)"
486
+ )
487
+ return CheckResult(
488
+ name="docker-compose",
489
+ status=CheckStatus.PASS,
490
+ message=version_line,
491
+ )
492
+
493
+
494
+ def _check_wsl_memory(
495
+ home: Optional[Path] = None,
496
+ total_ram_gb: Optional[Callable[[], Optional[float]]] = None,
497
+ platform: Optional[str] = None,
498
+ ) -> CheckResult:
499
+ """O6 (opt-batch, D-11): WSL2 memory guidance on low-RAM Windows hosts.
500
+
501
+ Docker Desktop's WSL2 backend defaults to ~50%/8GB of host RAM (whichever
502
+ is less) for the utility VM — on an 8GB machine the VM, Vmmem, and the
503
+ host then fight for memory and the container side (cc-switch daemon OOM,
504
+ see O5) suffers first. When the host has ≤8GB and `%USERPROFILE%\\.wslconfig`
505
+ does not cap `memory` under [wsl2], surface a WARN with the recommended
506
+ snippet. Advisory ONLY — never edits system config. Non-Windows hosts SKIP.
507
+ """
508
+ if (platform or sys.platform) != "win32":
509
+ return CheckResult(name="wsl-memory", status=CheckStatus.SKIP,
510
+ message="Windows-only check")
511
+
512
+ home = home or Path.home()
513
+ cfg = home / ".wslconfig"
514
+
515
+ def _default_total_ram_gb() -> Optional[float]:
516
+ import ctypes
517
+
518
+ class MEMORYSTATUSEX(ctypes.Structure):
519
+ _fields_ = [
520
+ ("dwLength", ctypes.c_ulong),
521
+ ("dwMemoryLoad", ctypes.c_ulong),
522
+ ("ullTotalPhys", ctypes.c_ulonglong),
523
+ ("ullAvailPhys", ctypes.c_ulonglong),
524
+ ("ullTotalPageFile", ctypes.c_ulonglong),
525
+ ("ullAvailPageFile", ctypes.c_ulonglong),
526
+ ("ullTotalVirtual", ctypes.c_ulonglong),
527
+ ("ullAvailVirtual", ctypes.c_ulonglong),
528
+ ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
529
+ ]
530
+
531
+ stat = MEMORYSTATUSEX()
532
+ stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
533
+ if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
534
+ return None
535
+ return stat.ullTotalPhys / (1024 ** 3)
536
+
537
+ ram = (total_ram_gb or _default_total_ram_gb)()
538
+ if ram is None:
539
+ return CheckResult(name="wsl-memory", status=CheckStatus.SKIP,
540
+ message="Could not read physical memory")
541
+
542
+ capped = False
543
+ try:
544
+ if cfg.is_file():
545
+ in_wsl2 = False
546
+ for line in cfg.read_text(encoding="utf-8", errors="replace").splitlines():
547
+ stripped = line.strip()
548
+ if stripped.startswith("["):
549
+ in_wsl2 = stripped.lower() == "[wsl2]"
550
+ elif in_wsl2 and stripped.lower().startswith("memory"):
551
+ capped = True # any memory= cap counts, value not judged
552
+ break
553
+ except OSError:
554
+ pass
555
+
556
+ if capped or ram > 8.5:
557
+ return CheckResult(
558
+ name="wsl-memory",
559
+ status=CheckStatus.PASS,
560
+ message=(
561
+ f"物理内存 {ram:.0f}GB"
562
+ + (",.wslconfig 已设 memory 上限" if capped else ",无需 WSL 内存上限")
563
+ ),
564
+ )
565
+ return CheckResult(
566
+ name="wsl-memory",
567
+ status=CheckStatus.WARN,
568
+ message=(
569
+ f"物理内存 {ram:.0f}GB 且 .wslconfig 未限制 WSL2 内存——"
570
+ "Docker 的 WSL2 虚拟机默认可占约 50% 内存,低配机上易与宿主争抢"
571
+ "(容器侧 cc-switch daemon 可能被 OOM)"
572
+ ),
573
+ hint=(
574
+ "建议在 %USERPROFILE%\\.wslconfig 添加:\n"
575
+ "[wsl2]\n"
576
+ f"memory={'4GB' if ram <= 4.5 else '5GB'}\n"
577
+ "保存后运行 `wsl --shutdown` 生效(仅建议,AISC 不会自动修改)"
578
+ ),
579
+ )
580
+
581
+
582
+ def _check_root_writable(root: Optional[Path]) -> CheckResult:
583
+ """Check 10: Project root directory writability (read-only diagnostic)."""
584
+ if root is None:
585
+ return CheckResult(
586
+ name="root-writable",
587
+ status=CheckStatus.SKIP,
588
+ message="No AISC root found — writability check skipped",
589
+ )
590
+ try:
591
+ if not root.exists():
592
+ return CheckResult(
593
+ name="root-writable",
594
+ status=CheckStatus.WARN,
595
+ message=f"Root directory does not exist: {root}",
596
+ hint="Verify the project root path is correct",
597
+ )
598
+ if not root.is_dir():
599
+ return CheckResult(
600
+ name="root-writable",
601
+ status=CheckStatus.WARN,
602
+ message=f"Root path is not a directory: {root}",
603
+ hint="The project root must be a directory",
604
+ )
605
+ if os.access(str(root), os.W_OK):
606
+ return CheckResult(
607
+ name="root-writable",
608
+ status=CheckStatus.PASS,
609
+ message=f"Root directory is writable: {root}",
610
+ )
611
+ else:
612
+ return CheckResult(
613
+ name="root-writable",
614
+ status=CheckStatus.WARN,
615
+ message=f"Root directory may not be writable: {root}",
616
+ detail="Permission pre-check only — not a write guarantee",
617
+ hint="Check directory permissions (e.g. ls -ld on the directory)",
618
+ )
619
+ except OSError as exc:
620
+ return CheckResult(
621
+ name="root-writable",
622
+ status=CheckStatus.WARN,
623
+ message=f"Cannot check root writability: {root}",
624
+ detail=str(exc),
625
+ )
626
+
627
+
628
+ # ---------------------------------------------------------------------------
629
+ # Exit code priority logic
630
+ # ---------------------------------------------------------------------------
631
+
632
+ EXIT_OK = 0
633
+ EXIT_GENERAL = 1
634
+ EXIT_USAGE = 2
635
+ EXIT_DOCKER_UNAVAILABLE = 3
636
+ EXIT_PERMISSION_DENIED = 9
637
+
638
+ ERR_GENERAL = "AISC_ERR_GENERAL"
639
+ ERR_DOCKER_UNAVAILABLE = "AISC_ERR_DOCKER_UNAVAILABLE"
640
+ ERR_PERMISSION_DENIED = "AISC_ERR_PERMISSION_DENIED"
641
+
642
+
643
+ def _compute_exit_code(checks: List[CheckResult]) -> tuple:
644
+ """Compute final exit code and error details from a completed check list."""
645
+ docker_cli_fail = any(
646
+ c.name == "docker-cli" and c.status == CheckStatus.FAIL for c in checks
647
+ )
648
+ docker_daemon_fail = any(
649
+ c.name == "docker-daemon" and c.status == CheckStatus.FAIL for c in checks
650
+ )
651
+ docker_permission_fail = any(
652
+ c.name == "docker-permission" and c.status == CheckStatus.FAIL for c in checks
653
+ )
654
+ explicit_root_invalid = any(
655
+ c.name == "aisc-root" and c.status == CheckStatus.FAIL for c in checks
656
+ )
657
+ any_other_fail = any(
658
+ c.status == CheckStatus.FAIL
659
+ and c.name
660
+ not in ("docker-cli", "docker-daemon", "docker-permission", "aisc-root")
661
+ for c in checks
662
+ )
663
+
664
+ if docker_cli_fail or docker_daemon_fail:
665
+ return EXIT_DOCKER_UNAVAILABLE, ERR_DOCKER_UNAVAILABLE, "Docker is not available"
666
+
667
+ if docker_permission_fail:
668
+ return EXIT_PERMISSION_DENIED, ERR_PERMISSION_DENIED, "Docker permission denied"
669
+
670
+ if explicit_root_invalid or any_other_fail:
671
+ return EXIT_GENERAL, ERR_GENERAL, "One or more checks failed"
672
+
673
+ return EXIT_OK, None, None
674
+
675
+
676
+ # ---------------------------------------------------------------------------
677
+ # Public API
678
+ # ---------------------------------------------------------------------------
679
+
680
+ CheckFunc = Callable[[], CheckResult]
681
+ CheckListFunc = Callable[[], List[CheckResult]]
682
+
683
+
684
+ def run_doctor(
685
+ runner: Optional[ProcessRunner] = None,
686
+ root: Optional[Path] = None,
687
+ root_error: Optional[str] = None,
688
+ which: Optional[Callable[[str], Optional[str]]] = None,
689
+ ) -> DoctorReport:
690
+ """Run all host doctor checks and return a ``DoctorReport``.
691
+
692
+ Parameters
693
+ ----------
694
+ runner:
695
+ Process runner. Defaults to ``RealProcessRunner``.
696
+ root:
697
+ Pre-located AISC root (may be ``None``).
698
+ root_error:
699
+ Original error message when an explicit or env root source was
700
+ invalid. Passed through verbatim to the aisc-root check.
701
+ which:
702
+ Command-discovery callable. Defaults to ``shutil.which``.
703
+ When ``which('docker')`` returns ``None``, docker-cli is **FAIL**
704
+ (exit 3 / AISC_ERR_DOCKER_UNAVAILABLE) and subsequent docker
705
+ sub-command checks are SKIP with zero subprocess calls.
706
+ """
707
+ import shutil as _shutil
708
+
709
+ r = runner or RealProcessRunner()
710
+ w: Callable[[str], Optional[str]] = which if which is not None else _shutil.which
711
+
712
+ docker_raw = w("docker") # may be None when not on PATH / injected None-return
713
+ git_raw = w("git")
714
+
715
+ checks: List[CheckResult] = []
716
+
717
+ # 1. docker-cli
718
+ # which('docker') is None → FAIL (Docker CLI not found, exit 3)
719
+ # which('docker') returns a path → run --version check
720
+ if docker_raw is None:
721
+ docker_cli = CheckResult(
722
+ name="docker-cli",
723
+ status=CheckStatus.FAIL,
724
+ message="Docker CLI not found",
725
+ detail="docker command not available on PATH",
726
+ hint="Install Docker: https://docs.docker.com/get-docker/",
727
+ )
728
+ docker_cli_available = False
729
+ docker_path = None
730
+ else:
731
+ docker_path = docker_raw
732
+ docker_cli = _check_docker_cli(docker_path, r)
733
+ docker_cli_available = docker_cli.status != CheckStatus.FAIL
734
+ checks.append(docker_cli)
735
+
736
+ # 2. docker-daemon (SKIP when CLI unavailable — no subprocess call)
737
+ if docker_path and docker_cli_available:
738
+ docker_daemon = _check_docker_daemon(docker_path, r, docker_cli_available)
739
+ else:
740
+ docker_daemon = CheckResult(
741
+ name="docker-daemon",
742
+ status=CheckStatus.SKIP,
743
+ message="Docker CLI not available",
744
+ )
745
+ checks.append(docker_daemon)
746
+ docker_daemon_failed = docker_daemon.status == CheckStatus.FAIL
747
+
748
+ # 3. docker-permission (SKIP when CLI/daemon unavailable)
749
+ if docker_path and docker_cli_available:
750
+ docker_perm = _check_docker_permission(
751
+ docker_path, r, docker_cli_available, docker_daemon_failed
752
+ )
753
+ else:
754
+ docker_perm = CheckResult(
755
+ name="docker-permission",
756
+ status=CheckStatus.SKIP,
757
+ message="Docker CLI not available",
758
+ )
759
+ checks.append(docker_perm)
760
+
761
+ # 4. buildx (SKIP when CLI/daemon unavailable)
762
+ if docker_path and docker_cli_available:
763
+ buildx = _check_docker_buildx(
764
+ docker_path, r, docker_cli_available, docker_daemon_failed
765
+ )
766
+ else:
767
+ buildx = CheckResult(
768
+ name="docker-buildx",
769
+ status=CheckStatus.SKIP,
770
+ message="Docker CLI not available",
771
+ )
772
+ checks.append(buildx)
773
+
774
+ # 5. TUN
775
+ checks.append(_check_tun_device())
776
+
777
+ # 6. aisc root — pass through root_error verbatim
778
+ checks.append(_check_aisc_root(root, root_error=root_error))
779
+
780
+ # 6b. aisc-bundle store (0.1.0 A3, guide 3.3.4): append-only check —
781
+ # the pip install form has no repo, so the aisc-root WARN above is
782
+ # misleading there. This check names the fetch path explicitly.
783
+ checks.append(_check_bundle_store(root))
784
+
785
+ # 6c. A8 (guide 3.5.5): channel + platform-support — multi-channel
786
+ # installs are the #1 "upgraded but still old version" confusion.
787
+ checks.append(_check_channel_confusion())
788
+ checks.append(_check_platform_support())
789
+
790
+ # 7. root files
791
+ checks.extend(_check_root_files(root))
792
+
793
+ # 8. git (WARN when missing, not SKIP)
794
+ if git_raw:
795
+ checks.append(_check_git(git_raw, r))
796
+ else:
797
+ checks.append(
798
+ CheckResult(
799
+ name="git",
800
+ status=CheckStatus.WARN,
801
+ message="Git not available",
802
+ )
803
+ )
804
+
805
+ # 9. docker-compose
806
+ checks.append(_check_docker_compose(docker_path, r, docker_cli_available))
807
+
808
+ # 10. root writability
809
+ checks.append(_check_root_writable(root))
810
+
811
+ # 11. WSL2 memory guidance (O6: WARN-only advisory, never a gate)
812
+ checks.append(_check_wsl_memory())
813
+
814
+ exit_code, error_code, error_message = _compute_exit_code(checks)
815
+
816
+ return DoctorReport(
817
+ checks=checks,
818
+ exit_code=exit_code,
819
+ error_code=error_code,
820
+ error_message=error_message,
821
+ )