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
aisc/domain/models.py ADDED
@@ -0,0 +1,674 @@
1
+ """Domain models for AISC CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Core enums / constants
12
+ # ---------------------------------------------------------------------------
13
+
14
+ class CheckStatus:
15
+ """Status values for a single doctor check."""
16
+ PASS = "pass"
17
+ WARN = "warn"
18
+ FAIL = "fail"
19
+ SKIP = "skip"
20
+
21
+
22
+ class RuntimeErrorCode:
23
+ """Stable error codes for runtime operations.
24
+
25
+ Aligned with docs/rfc/aisc-cli-v1.md §4.1.
26
+ """
27
+ DOCKER_UNAVAILABLE = "AISC_ERR_DOCKER_UNAVAILABLE"
28
+ RUNTIME_CONFLICT = "AISC_ERR_RUNTIME_CONFLICT"
29
+ INVALID_RUNTIME_ID = "AISC_ERR_INVALID_RUNTIME_ID"
30
+ RUNTIME_OPERATION_FAILED = "AISC_ERR_RUNTIME_OPERATION_FAILED"
31
+ WORKSPACE_INVALID = "AISC_ERR_WORKSPACE_INVALID"
32
+ IMAGE_NOT_FOUND = "AISC_ERR_IMAGE_NOT_FOUND"
33
+ NETWORK_INVALID = "AISC_ERR_NETWORK_INVALID"
34
+ SCOPE_INVALID = "AISC_ERR_SCOPE_INVALID"
35
+ # Session-specific codes (S0.3)
36
+ SESSION_NOT_FOUND = "AISC_ERR_SESSION_NOT_FOUND"
37
+ SESSION_FAILED = "AISC_ERR_SESSION_FAILED"
38
+ RUNTIME_NOT_RUNNING = "AISC_ERR_RUNTIME_NOT_RUNNING"
39
+ INVALID_SESSION_ID = "AISC_ERR_INVALID_SESSION_ID"
40
+ INVALID_AGENT = "AISC_ERR_INVALID_AGENT"
41
+ # Provider status codes (S0.4)
42
+ PROVIDER_STATUS_FAILED = "AISC_ERR_PROVIDER_STATUS_FAILED"
43
+ # Agent Artifact codes (Stage 3, ART-02)
44
+ ARTIFACT_INVALID = "AISC_ERR_ARTIFACT_INVALID"
45
+ ARTIFACT_NOT_FOUND = "AISC_ERR_ARTIFACT_NOT_FOUND"
46
+ # Extended codes: STATE_LOCK_TIMEOUT is registered in RFC §4.1 (exit 17);
47
+ # the rest are extended codes not in the RFC exit-code table.
48
+ RUNTIME_NOT_FOUND = "AISC_ERR_RUNTIME_NOT_FOUND"
49
+ STATE_LOCK_TIMEOUT = "AISC_ERR_STATE_LOCK_TIMEOUT"
50
+ RUNTIME_UNHEALTHY = "AISC_ERR_RUNTIME_UNHEALTHY"
51
+ CONTAINER_NOT_FOUND = "AISC_ERR_CONTAINER_NOT_FOUND"
52
+ # Workspace lease / reconcile (runtime-lifecycle-ux Stage 1,
53
+ # 02-domain-contract.md §3 error codes)
54
+ ACTIVE_WORKSPACE_LEASE = "AISC_ERR_ACTIVE_WORKSPACE_LEASE"
55
+ RUNTIME_OWNER_UNKNOWN = "AISC_ERR_RUNTIME_OWNER_UNKNOWN"
56
+ RUNTIME_RECONCILE_FAILED = "AISC_ERR_RUNTIME_RECONCILE_FAILED"
57
+ RUNTIME_LEASE_CONFLICT = "AISC_ERR_RUNTIME_LEASE_CONFLICT"
58
+
59
+
60
+ class RuntimeExitCode:
61
+ """Exit codes for runtime operations.
62
+
63
+ Uses RFC-compliant exit codes. New runtime-specific codes use 14+.
64
+ Aligned with docs/rfc/aisc-cli-v1.md §4.1.
65
+ """
66
+ SUCCESS = 0
67
+ GENERAL_ERROR = 1
68
+ USAGE_ERROR = 2
69
+ DOCKER_UNAVAILABLE = 3 # Reuses existing AISC_EXIT_DOCKER_UNAVAILABLE
70
+ # 4 = AISC_EXIT_BUILD_FAILED (reserved by RFC)
71
+ IMAGE_NOT_FOUND = 5 # AISC_EXIT_IMAGE_NOT_FOUND (reserved by RFC)
72
+ # 6 = AISC_EXIT_CONFIG_INVALID (reserved by RFC)
73
+ PERMISSION_DENIED = 9
74
+ # New runtime-specific exit codes (14+, registered in RFC)
75
+ RUNTIME_CONFLICT = 14 # AISC_EXIT_RUNTIME_CONFLICT
76
+ INVALID_RUNTIME_ID = 15 # AISC_EXIT_INVALID_RUNTIME_ID
77
+ RUNTIME_OPERATION_FAILED = 16 # AISC_EXIT_RUNTIME_OPERATION_FAILED
78
+ STATE_LOCK_TIMEOUT = 17 # AISC_EXIT_STATE_LOCK_TIMEOUT
79
+ # Session-specific exit codes (S0.3, registered in RFC §4.1)
80
+ SESSION_NOT_FOUND = 18 # AISC_EXIT_SESSION_NOT_FOUND
81
+ SESSION_FAILED = 19 # AISC_EXIT_SESSION_FAILED
82
+ RUNTIME_NOT_RUNNING = 20 # AISC_EXIT_RUNTIME_NOT_RUNNING
83
+ # Provider status exit code (S0.4)
84
+ PROVIDER_STATUS_FAILED = 21 # AISC_EXIT_PROVIDER_STATUS_FAILED
85
+ # Workspace lease / reconcile (runtime-lifecycle-ux Stage 1)
86
+ ACTIVE_WORKSPACE_LEASE = 22 # AISC_EXIT_ACTIVE_WORKSPACE_LEASE
87
+ RUNTIME_RECONCILE_FAILED = 23 # AISC_EXIT_RUNTIME_RECONCILE_FAILED
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Version info
92
+ # ---------------------------------------------------------------------------
93
+
94
+ # Workbench capability negotiation (05-cli-gui-contract.md §四).
95
+ # Advertised by ``aisc version --format json`` so the Workbench gates UI on
96
+ # what this CLI actually implements -- it must not guess from the version
97
+ # string. Add a key here as each capability ships.
98
+ WORKBENCH_CAPABILITIES = {
99
+ "runtime": "aisc.runtime/v1", # S0.2
100
+ "session": "aisc.session/v1", # S0.3
101
+ "providerStatus": "aisc.provider-status/v1", # S0.4
102
+ "buildEvents": "aisc.build-events/v2", # S0.5
103
+ "runtimeServices": "aisc.runtime-services/v1", # svc-2 (web gateway)
104
+ }
105
+
106
+
107
+ @dataclass
108
+ class VersionInfo:
109
+ """Structured version information gathered from the environment.
110
+
111
+ Six fixed keys per RFC; unknown values are ``None``.
112
+ """
113
+
114
+ cli_version: str
115
+ python_version: str
116
+ bundle_version: Optional[str] = None
117
+ declared_claude_version: Optional[str] = None
118
+ image_version: Optional[str] = None
119
+ contract_version: Optional[str] = None
120
+ # A8 (guide 3.5.5): text-mode provenance line only — NOT in to_dict
121
+ # (the JSON envelope keeps its 6 fixed keys + capabilities contract).
122
+ install_channel: Optional[str] = None
123
+
124
+ def to_dict(self) -> dict:
125
+ """Return RFC-compliant dict with 6 fixed keys + Workbench capabilities."""
126
+ return {
127
+ "cli_version": self.cli_version,
128
+ "bundle_version": self.bundle_version,
129
+ "contract_version": self.contract_version,
130
+ "image_version": self.image_version,
131
+ "claude_version": self.declared_claude_version,
132
+ "python_version": self.python_version,
133
+ "capabilities": WORKBENCH_CAPABILITIES,
134
+ }
135
+
136
+ def to_text(self) -> str:
137
+ lines = [
138
+ f"AISC CLI version : {self.cli_version}",
139
+ f"Python version : {self.python_version}",
140
+ ]
141
+ if self.install_channel:
142
+ lines.append(f"Install channel : {self.install_channel}")
143
+ if self.bundle_version is not None:
144
+ lines.append(f"Bundle version : {self.bundle_version}")
145
+ else:
146
+ lines.append("Bundle version : (not found)")
147
+ if self.image_version is not None:
148
+ lines.append(f"Image version : {self.image_version}")
149
+ if self.contract_version is not None:
150
+ lines.append(f"Contract version : {self.contract_version}")
151
+ if self.declared_claude_version is not None:
152
+ lines.append(f"Claude Code version: {self.declared_claude_version}")
153
+ else:
154
+ lines.append("Claude Code version: (not found)")
155
+ if WORKBENCH_CAPABILITIES:
156
+ lines.append("Capabilities : " + ", ".join(WORKBENCH_CAPABILITIES.keys()))
157
+ return "\n".join(lines)
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Doctor
162
+ # ---------------------------------------------------------------------------
163
+
164
+ @dataclass
165
+ class CheckResult:
166
+ """Result of a single doctor check."""
167
+
168
+ name: str
169
+ status: str # pass / warn / fail / skip
170
+ message: str = ""
171
+ detail: Optional[str] = None
172
+ hint: Optional[str] = None
173
+
174
+ def to_dict(self) -> dict:
175
+ d: dict = {"name": self.name, "status": self.status, "message": self.message}
176
+ if self.detail is not None:
177
+ d["detail"] = self.detail
178
+ if self.hint is not None:
179
+ d["hint"] = self.hint
180
+ return d
181
+
182
+
183
+ @dataclass
184
+ class DoctorReport:
185
+ """Full doctor report containing all checks and a summary."""
186
+
187
+ checks: List[CheckResult] = field(default_factory=list)
188
+ exit_code: int = 0
189
+ error_code: Optional[str] = None
190
+ error_message: Optional[str] = None
191
+
192
+ @property
193
+ def summary(self) -> dict:
194
+ passed = sum(1 for c in self.checks if c.status == CheckStatus.PASS)
195
+ warnings = sum(1 for c in self.checks if c.status == CheckStatus.WARN)
196
+ failures = sum(1 for c in self.checks if c.status == CheckStatus.FAIL)
197
+ skipped = sum(1 for c in self.checks if c.status == CheckStatus.SKIP)
198
+ return {
199
+ "passed": passed,
200
+ "warnings": warnings,
201
+ "failures": failures,
202
+ "skipped": skipped,
203
+ }
204
+
205
+ def to_dict(self) -> dict:
206
+ return {
207
+ "checks": [c.to_dict() for c in self.checks],
208
+ "summary": self.summary,
209
+ }
210
+
211
+ def add_check(self, check: CheckResult) -> None:
212
+ self.checks.append(check)
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # CLI error for controlled exits
217
+ # ---------------------------------------------------------------------------
218
+
219
+ @dataclass
220
+ class CliError(Exception):
221
+ """Controlled CLI error with exit code, stable error code, and optional
222
+ structured outcome data for JSON envelope / events terminal."""
223
+
224
+ message: str
225
+ exit_code: int = 1
226
+ error_code: str = "AISC_ERR_GENERAL"
227
+ hint: Optional[str] = None
228
+ data: Optional[Dict[str, Any]] = None # structured outcome preserved on failure
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Process result (for adapter)
233
+ # ---------------------------------------------------------------------------
234
+
235
+ @dataclass
236
+ class ProcessResult:
237
+ """Result of a subprocess execution."""
238
+
239
+ stdout: str = ""
240
+ stderr: str = ""
241
+ exit_code: int = -1
242
+ timed_out: bool = False
243
+ command_not_found: bool = False
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Docker preflight result
248
+ # ---------------------------------------------------------------------------
249
+
250
+ @dataclass(frozen=True)
251
+ class DockerPreflightResult:
252
+ """Result of Docker availability preflight check."""
253
+
254
+ docker_path: str = ""
255
+ available: bool = False
256
+ reason: str = "" # "ok", "cli_not_found", "daemon_unreachable", "permission_denied"
257
+
258
+ @property
259
+ def exit_code(self) -> int:
260
+ """Map preflight result to AISC exit code."""
261
+ if self.available:
262
+ return 0
263
+ if self.reason == "permission_denied":
264
+ return 9 # AISC_EXIT_PERMISSION_DENIED
265
+ return 3 # AISC_EXIT_DOCKER_UNAVAILABLE
266
+
267
+ @property
268
+ def error_code(self) -> str:
269
+ """Map preflight result to AISC error code."""
270
+ if self.available:
271
+ return ""
272
+ if self.reason == "permission_denied":
273
+ return "AISC_ERR_PERMISSION_DENIED"
274
+ return "AISC_ERR_DOCKER_UNAVAILABLE"
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Docker image inspect — structured result
279
+ # ---------------------------------------------------------------------------
280
+
281
+ class ImageInspectStatus:
282
+ """Classification of a docker image inspect call."""
283
+ EXISTS = "exists"
284
+ MISSING = "missing"
285
+ DOCKER_UNAVAILABLE = "docker_unavailable"
286
+ PERMISSION_DENIED = "permission_denied"
287
+ TIMEOUT = "timeout"
288
+ ERROR = "error"
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class ImageInspectResult:
293
+ """Structured result of ``docker image inspect``.
294
+
295
+ Only ``status == MISSING`` maps to AISC_EXIT_IMAGE_NOT_FOUND(5);
296
+ other non-ok statuses map to DOCKER_UNAVAILABLE(3), PERMISSION_DENIED(9),
297
+ or GENERAL(1) depending on the underlying cause.
298
+
299
+ ``image_id`` (容器随镜像同步更新, KI-4 挂账) carries the content-addressed
300
+ ``.Id`` on the EXISTS path — empty when unparseable or non-ok; existence
301
+ remains the primary question, the ID is opportunistic metadata.
302
+ """
303
+
304
+ status: str = ImageInspectStatus.ERROR # one of ImageInspectStatus
305
+ image: str = ""
306
+ message: str = ""
307
+ image_id: str = ""
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Docker build / run plans (immutable, no side effects)
312
+ # ---------------------------------------------------------------------------
313
+
314
+ @dataclass(frozen=True)
315
+ class BuildPlan:
316
+ """Immutable build specification — argv, tag, flags, root."""
317
+
318
+ tag: str = "super-claude:latest"
319
+ root: str = ""
320
+ dockerfile: str = ""
321
+ no_cache: bool = False
322
+ pull: bool = False
323
+ build_arg_use_cn_mirror: str = "1"
324
+ build_arg_node_image: str = "node:20-slim"
325
+ # A5 (D-9): forwarded tool pins — factory default under the data-root
326
+ # user overlay (D-27); "latest" keeps the pre-A5 behavior when the
327
+ # factory file carries no pin.
328
+ build_arg_claude_code_version: str = "latest"
329
+ build_arg_codex_version: str = "latest"
330
+ # T8a (2.1.9 D-9): fallback registry prefixes for the host-side pre-pull
331
+ # chain (versions.env NODE_IMAGE_MIRRORS). Each entry joins the bare
332
+ # node_image name; the first candidate is always build_arg_node_image.
333
+ node_image_mirrors: tuple = ()
334
+ dry_run: bool = False
335
+ # Stage 8 (CS-01/CS-02): the resolver-pinned cc-switch release. Empty
336
+ # strings = manual/legacy `docker build` (Dockerfile ARG fallback path,
337
+ # documented as non-reproducible); `aisc build` always injects these.
338
+ cc_switch_version: str = ""
339
+ cc_switch_commit: str = ""
340
+ cc_switch_asset_url: str = ""
341
+ cc_switch_asset_sha256: str = ""
342
+ cc_switch_asset_name: str = ""
343
+ cc_switch_manifest: str = "" # compact JSON for the org.aisc.build-manifest label
344
+
345
+ @property
346
+ def docker_argv(self) -> list:
347
+ """Return the ``docker build`` argv list (without ``docker``)."""
348
+ argv = ["build"]
349
+ if self.no_cache:
350
+ argv.append("--no-cache")
351
+ if self.pull:
352
+ argv.append("--pull")
353
+ from aisc.domain.docker_ownership import image_labels, label_args
354
+
355
+ argv.extend([
356
+ "--build-arg", f"USE_CN_MIRROR={self.build_arg_use_cn_mirror}",
357
+ "--build-arg", f"NODE_IMAGE={self.build_arg_node_image}",
358
+ "--build-arg", f"CLAUDE_CODE_VERSION={self.build_arg_claude_code_version}",
359
+ "--build-arg", f"CODEX_VERSION={self.build_arg_codex_version}",
360
+ ])
361
+ # docker-resource-lifecycle A2: provenance labels injected by the
362
+ # unified build argv (org.aisc.*; 02 §1.2 — never only host logs).
363
+ import aisc as _aisc
364
+ argv.extend(label_args(image_labels(getattr(_aisc, "__version__", "") or "dev")))
365
+ if self.cc_switch_version:
366
+ argv.extend([
367
+ "--build-arg", f"CC_SWITCH_RESOLVED_VERSION={self.cc_switch_version}",
368
+ "--build-arg", f"CC_SWITCH_RELEASE_COMMIT={self.cc_switch_commit}",
369
+ "--build-arg", f"CC_SWITCH_ASSET_URL={self.cc_switch_asset_url}",
370
+ "--build-arg", f"CC_SWITCH_ASSET_SHA256={self.cc_switch_asset_sha256}",
371
+ "--build-arg", f"CC_SWITCH_ASSET_NAME={self.cc_switch_asset_name}",
372
+ "--build-arg", f"CC_SWITCH_BUILD_MANIFEST={self.cc_switch_manifest}",
373
+ ])
374
+ argv.extend([
375
+ "-f", self.dockerfile,
376
+ "-t", self.tag,
377
+ self.root,
378
+ ])
379
+ return argv
380
+
381
+
382
+ @dataclass(frozen=True)
383
+ class RunPlan:
384
+ """Immutable run specification — argv, image, workspace, network, flags."""
385
+
386
+ image: str = "super-claude:latest"
387
+ workspace: str = ""
388
+ name: str = ""
389
+ network: str = "direct"
390
+ dry_run: bool = False
391
+ interactive: bool = True # True for text mode, False for json/events
392
+ non_interactive: bool = False # --non-interactive: omit -it, add env vars, DEVNULL stdin
393
+ proxy_config: str = "" # host path to .claude/mihomo/config.yaml (when network=proxy)
394
+ label: str = "" # optional container label for multi-container addressing
395
+ keep_alive: bool = False # --keep-alive: omit --rm, keep container after exit
396
+ # Stage 7 (DATA-01): data-root workspaces/<hash>/ dir; when set (and the
397
+ # run is project-scoped) the agent config dirs mount from here instead
398
+ # of being copied into the workspace.
399
+ agent_state_root: str = ""
400
+ # svc-5 (web gateway): loopback host port publishing the container-side
401
+ # gateway (45871/tcp); 0 would mean "no gateway" but plan_run always
402
+ # allocates one — one-shot `aisc run` containers get the same capability
403
+ # as managed runtimes.
404
+ web_gateway_host_port: int = 0
405
+ # runtime-lifecycle-ux 3a: host-side persistent toolchain dir (project
406
+ # scope one-shots get the same capability as managed runtimes); empty =
407
+ # no toolchain mount (temporary mode -> container-side /tmp layout).
408
+ toolchain_root: str = ""
409
+
410
+ @property
411
+ def docker_argv(self) -> list:
412
+ """Return the ``docker run`` argv list (without ``docker``).
413
+
414
+ - interactive=True → includes ``-it``
415
+ - interactive=False → omits ``-it``
416
+ - non_interactive=True → also omits ``-it``, adds AISC_NON_INTERACTIVE + CLAUDE_SCOPE
417
+ - network=proxy → adds NET_ADMIN, TUN device, mihomo config mount
418
+ - keep_alive=False → includes ``--rm`` (default: remove on exit)
419
+ - keep_alive=True → omits ``--rm`` (persist after exit)
420
+ - agent_state_root → project-scope mounts: claude/codex/cc-switch
421
+ config + daemon runtime state from the data root
422
+ - Runs as the image default user (root) so bind-mounted WSL2 files remain writable
423
+ """
424
+ argv = ["run"]
425
+
426
+ # Only add --rm if keep_alive is False (default behavior)
427
+ if not self.keep_alive:
428
+ argv.append("--rm")
429
+
430
+ # F2-C: keep-alive runs are DETACHED, period — `aisc run` activates a
431
+ # workspace and exits; the interactive surface is `aisc shell`/agents.
432
+ if self.keep_alive:
433
+ argv.append("-d")
434
+ elif self.interactive and not self.non_interactive:
435
+ argv.append("-it")
436
+ from aisc.domain.docker_ownership import label_args, one_shot_labels
437
+
438
+ argv.extend([
439
+ "-e", "TERM=xterm-256color",
440
+ # F2-C: detached activations ride the entrypoint's IDLE mode
441
+ # (exec sleep infinity) — the image default CMD exits without a
442
+ # TTY, so a bare `docker run -d` died within a second.
443
+ "-e", "AISC_RUNTIME_MODE=idle",
444
+ "--name", self.name,
445
+ # runtime-lifecycle A2 / docker-ownership A0: labels prove
446
+ # ownership for the maintenance classifier (kind=one-shot).
447
+ *label_args(one_shot_labels()),
448
+ "-v", f"{self.workspace}:/root/app",
449
+ ])
450
+ if self.agent_state_root:
451
+ base = self.agent_state_root.rstrip("/\\")
452
+ argv.extend([
453
+ "-v", f"{base}/claude:/root/.claude",
454
+ "-v", f"{base}/codex:/root/.codex",
455
+ "-v", f"{base}/cc-switch:/root/.cc-switch",
456
+ "-v", f"{base}/runtime:/root/.local/state/cc-switch",
457
+ ])
458
+ if self.toolchain_root:
459
+ from aisc.domain.toolchain import TOOLCHAIN_MOUNT_TARGET
460
+
461
+ argv.extend(["-v", f"{self.toolchain_root}:{TOOLCHAIN_MOUNT_TARGET}"])
462
+ if self.non_interactive:
463
+ argv.extend([
464
+ "-e", "AISC_NON_INTERACTIVE=1",
465
+ "-e", "CLAUDE_SCOPE=project",
466
+ ])
467
+ if self.network == "proxy":
468
+ argv.extend([
469
+ "--cap-add=NET_ADMIN",
470
+ "--device", "/dev/net/tun",
471
+ ])
472
+ if self.proxy_config:
473
+ argv.extend([
474
+ "-v", f"{self.proxy_config}:/etc/mihomo/config.yaml:ro",
475
+ ])
476
+ if self.web_gateway_host_port:
477
+ # svc-5: loopback publish of the in-container web gateway.
478
+ from aisc.domain.web_services import WEB_GATEWAY_CONTAINER_PORT
479
+
480
+ argv.extend([
481
+ "--publish",
482
+ f"127.0.0.1:{self.web_gateway_host_port}:{WEB_GATEWAY_CONTAINER_PORT}/tcp",
483
+ ])
484
+ argv.append(self.image)
485
+ return argv
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # Runtime snapshot — structured runtime state
490
+ # ---------------------------------------------------------------------------
491
+
492
+ @dataclass
493
+ class RuntimeSnapshot:
494
+ """Structured runtime state matching lifecycle contract.
495
+
496
+ Combines registry metadata with live Docker state.
497
+ See docs/gui-planning/03-lifecycle-contract.md for state machine.
498
+ """
499
+ runtime_id: str = "" # UUID v4 (provided by Workbench)
500
+ state: str = "unknown" # unknown, not_found, starting, running, stopping, stopped, removing
501
+ workspace: str = "" # canonical absolute path
502
+ image: str = "" # image:tag
503
+ network: str = "direct" # direct, proxy
504
+ scope: str = "project" # project, temporary
505
+ owner: str = "" # who created this runtime (e.g., "workbench")
506
+ config_fingerprint: str = "" # sha256:<hash> of canonical config
507
+ container_name: str = "" # Docker container name (for legacy compat)
508
+ container_id: str = "" # Docker container ID (from inspect)
509
+ label: str = "" # optional user label
510
+
511
+ # Registry reconciliation
512
+ registry_state: str = "unknown" # registered, missing, unknown
513
+
514
+ # Timestamps
515
+ created_at: str = "" # ISO timestamp or numeric (backward compat)
516
+ started_at: str = "" # ISO timestamp from Docker
517
+ observed_at: str = "" # ISO timestamp when this snapshot was taken
518
+
519
+ # Staleness indicator
520
+ stale: bool = False # True if observation is potentially outdated
521
+
522
+ # svc-2 (web gateway): loopback gateway reachability per
523
+ # aisc.runtime-services/v1; None = not observed (list path / old CLI) —
524
+ # consumers treat absent as unavailable, never as a parse failure.
525
+ web_access: Optional[Dict[str, Any]] = None
526
+
527
+ # runtime-lifecycle-ux 3a: scope-derived dependency policy
528
+ # (persistent_toolchain | ephemeral_toolchain; "" on legacy records) and
529
+ # the host-side toolchain health summary. Both advisory — never gates.
530
+ dependency_policy: str = ""
531
+ toolchain: Optional[Dict[str, Any]] = None
532
+
533
+ # Last operation error (None if last operation succeeded)
534
+ last_operation_error: Optional[Dict[str, Any]] = None
535
+
536
+ def to_dict(self) -> Dict[str, Any]:
537
+ """Convert to JSON-serializable dict matching CLI contract."""
538
+ result = {
539
+ "runtime_id": self.runtime_id,
540
+ "state": self.state,
541
+ "config": {
542
+ "workspace": self.workspace,
543
+ "image": self.image,
544
+ "network": self.network,
545
+ "scope": self.scope,
546
+ },
547
+ "owner": self.owner,
548
+ "config_fingerprint": self.config_fingerprint,
549
+ "container_name": self.container_name,
550
+ "container_id": self.container_id,
551
+ "registry_state": self.registry_state,
552
+ "observed_at": self.observed_at,
553
+ "stale": self.stale,
554
+ }
555
+
556
+ # Optional fields
557
+ if self.label:
558
+ result["label"] = self.label
559
+ if self.created_at:
560
+ result["created_at"] = self.created_at
561
+ if self.started_at:
562
+ result["started_at"] = self.started_at
563
+ if self.web_access is not None:
564
+ result["web_access"] = self.web_access
565
+ if self.dependency_policy:
566
+ result["dependency_policy"] = self.dependency_policy
567
+ if self.toolchain is not None:
568
+ result["toolchain"] = self.toolchain
569
+ if self.last_operation_error:
570
+ result["last_operation_error"] = self.last_operation_error
571
+
572
+ return result
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # Session constants and models (S0.3)
577
+ # ---------------------------------------------------------------------------
578
+
579
+ class SessionAgent:
580
+ """Controlled agent enum for ``aisc session open``."""
581
+ CLAUDE = "claude"
582
+ CODEX = "codex"
583
+ BASH = "bash"
584
+ CC_SWITCH = "cc-switch"
585
+
586
+ ALL = (CLAUDE, CODEX, BASH, CC_SWITCH)
587
+
588
+
589
+ class SessionState:
590
+ """Session lifecycle states per 03-lifecycle-contract.md §5.1."""
591
+ STARTING = "starting"
592
+ RUNNING = "running"
593
+ CLOSING = "closing"
594
+ EXITED = "exited"
595
+ FAILED = "failed"
596
+ DISCONNECTED = "disconnected"
597
+
598
+
599
+ class SessionExitReason:
600
+ """Reasons for session termination per 03-lifecycle-contract.md §5.1."""
601
+ PROCESS_EXIT = "process_exit"
602
+ USER_CLOSE = "user_close"
603
+ RUNTIME_STOP = "runtime_stop"
604
+ TRANSPORT_ERROR = "transport_error"
605
+ WORKBENCH_CRASH_CLEANUP = "workbench_crash_cleanup"
606
+
607
+
608
+ @dataclass
609
+ class SessionRecord:
610
+ """Session metadata stored in-container at ``/run/aisc/sessions/<id>.json``.
611
+
612
+ Per contract 6.1: 0600, atomic write, no argv/env/output. Used only
613
+ for diagnostics and crash cleanup; never claims PTY recoverability.
614
+ """
615
+
616
+ schema_version: str = "aisc.session/v1"
617
+ runtime_id: str = ""
618
+ session_id: str = ""
619
+ agent: str = ""
620
+ state: str = SessionState.STARTING
621
+ pid: Optional[int] = None
622
+ pgid: Optional[int] = None
623
+ start_ticks: Optional[int] = None
624
+ started_at: str = ""
625
+ finished_at: str = ""
626
+ exit_code: Optional[int] = None
627
+ reason: str = ""
628
+
629
+ def to_dict(self) -> Dict[str, Any]:
630
+ return {
631
+ "schema_version": self.schema_version,
632
+ "runtime_id": self.runtime_id,
633
+ "session_id": self.session_id,
634
+ "agent": self.agent,
635
+ "state": self.state,
636
+ "pid": self.pid,
637
+ "pgid": self.pgid,
638
+ "start_ticks": self.start_ticks,
639
+ "started_at": self.started_at,
640
+ "finished_at": self.finished_at,
641
+ "exit_code": self.exit_code,
642
+ "reason": self.reason,
643
+ }
644
+
645
+
646
+ # ---------------------------------------------------------------------------
647
+ # Provider status model (S0.4)
648
+ # ---------------------------------------------------------------------------
649
+
650
+ @dataclass
651
+ class ProviderStatus:
652
+ """Observable provider status for one agent (05-cli-gui-contract.md §七).
653
+
654
+ Secret-free: only routing/auth metadata, never keys/tokens/cookies.
655
+ """
656
+
657
+ runtime_id: str
658
+ agent: str
659
+ provider_id: str
660
+ provider_name: str
661
+ route_mode: str
662
+ auth_status: str
663
+ observed_at: str = ""
664
+
665
+ def to_dict(self) -> Dict[str, Any]:
666
+ return {
667
+ "runtime_id": self.runtime_id,
668
+ "agent": self.agent,
669
+ "provider_id": self.provider_id,
670
+ "provider_name": self.provider_name,
671
+ "route_mode": self.route_mode,
672
+ "auth_status": self.auth_status,
673
+ "observed_at": self.observed_at,
674
+ }