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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|