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/cli/commands/run.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""``aisc run`` — Docker container run command.
|
|
2
|
+
|
|
3
|
+
Plans a ``docker run`` invocation based on user args, then optionally
|
|
4
|
+
executes it. Supports ``--events`` JSONL output.
|
|
5
|
+
|
|
6
|
+
**All** docker operations go through an injected ``DockerExecutor``;
|
|
7
|
+
this module never calls ``subprocess.run`` directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from aisc.domain.models import (
|
|
18
|
+
CliError,
|
|
19
|
+
ImageInspectStatus,
|
|
20
|
+
RunPlan,
|
|
21
|
+
)
|
|
22
|
+
from aisc.adapters.docker_ import (
|
|
23
|
+
DockerExecutor,
|
|
24
|
+
RealDockerExecutor,
|
|
25
|
+
format_argv_display,
|
|
26
|
+
validate_run_resources,
|
|
27
|
+
validate_proxy_config,
|
|
28
|
+
)
|
|
29
|
+
from aisc.cli.output import JsonlEmitter
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def _generate_container_name(base: str) -> str:
|
|
37
|
+
"""Generate a unique container name by appending a short random suffix."""
|
|
38
|
+
short = uuid.uuid4().hex[:8]
|
|
39
|
+
return f"{base}-{short}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Run service: plan the run
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def plan_run(
|
|
47
|
+
image: str = "super-claude:latest",
|
|
48
|
+
workspace: str = "",
|
|
49
|
+
name: str = "super-claude-station",
|
|
50
|
+
network: str = "direct",
|
|
51
|
+
dry_run: bool = False,
|
|
52
|
+
interactive: bool = True,
|
|
53
|
+
non_interactive: bool = False,
|
|
54
|
+
proxy_config: str = "",
|
|
55
|
+
label: str = "",
|
|
56
|
+
keep_alive: bool = False,
|
|
57
|
+
aisc_root: Optional[Path] = None,
|
|
58
|
+
) -> RunPlan:
|
|
59
|
+
"""Create an immutable ``RunPlan`` from user args.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
image: Docker image name (default: super-claude:latest).
|
|
63
|
+
workspace: Host path to bind-mount.
|
|
64
|
+
name: Container name prefix; unique suffix is appended.
|
|
65
|
+
network: ``"direct"`` or ``"proxy"``.
|
|
66
|
+
dry_run: Plan only, don't execute.
|
|
67
|
+
interactive: ``True`` → include ``-it``; ``False`` → omit.
|
|
68
|
+
non_interactive: ``True`` → omit ``-it``, add ``AISC_NON_INTERACTIVE=1``
|
|
69
|
+
and ``CLAUDE_SCOPE=project`` env vars, use DEVNULL for stdin.
|
|
70
|
+
proxy_config: Host path to ``.claude/mihomo/config.yaml``.
|
|
71
|
+
If omitted but ``network=proxy``, use
|
|
72
|
+
``<aisc_root>/.claude/mihomo/config.yaml``.
|
|
73
|
+
keep_alive: ``True`` → omit ``--rm``, keep container after exit.
|
|
74
|
+
aisc_root: AISC root path for proxy config resolution.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
A ``RunPlan`` with computed docker arguments.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
CliError: on workspace / proxy config validation failure.
|
|
81
|
+
"""
|
|
82
|
+
if ":" not in image:
|
|
83
|
+
image = f"{image}:latest"
|
|
84
|
+
|
|
85
|
+
ws_path = Path(workspace).resolve() if workspace else Path.cwd()
|
|
86
|
+
|
|
87
|
+
# Resolve proxy config (IDEA-2: data-root subscription first — the
|
|
88
|
+
# legacy <aisc_root>/.claude/mihomo/config.yaml is adopted once on first
|
|
89
|
+
# use; an explicit --proxy-config always wins).
|
|
90
|
+
resolved_proxy = proxy_config
|
|
91
|
+
if network == "proxy" and not resolved_proxy:
|
|
92
|
+
from aisc.application.network_subscription import (
|
|
93
|
+
resolve_subscription_config_path,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
resolved_proxy = resolve_subscription_config_path() or ""
|
|
97
|
+
|
|
98
|
+
# Stage 7 (DATA-01): agent config mounts from the data root. The dirs
|
|
99
|
+
# are created here (host side) so Windows bind mounts have real targets.
|
|
100
|
+
# Fail closed: an unusable data root stops the run — never fall back to
|
|
101
|
+
# copying agent state into the workspace.
|
|
102
|
+
from aisc.application.data_root import DataRootResolver
|
|
103
|
+
|
|
104
|
+
resolved_state = DataRootResolver().resolve(ws_path)
|
|
105
|
+
ws_state_dir = resolved_state.workspace_dir
|
|
106
|
+
for sub in ("claude", "codex", "cc-switch", "runtime"):
|
|
107
|
+
(ws_state_dir / sub).mkdir(parents=True, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
# svc-5: one gateway host port per one-shot run (same capability as
|
|
110
|
+
# managed runtimes). Ports reserved by other registered containers are
|
|
111
|
+
# skipped best-effort; dry-run shows the publish plan without Docker.
|
|
112
|
+
from aisc.application.web_gateway import (
|
|
113
|
+
allocate_gateway_host_port,
|
|
114
|
+
registry_host_ports,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
exclude: set = set()
|
|
118
|
+
if aisc_root is not None:
|
|
119
|
+
try:
|
|
120
|
+
from aisc.adapters.container_registry import list_containers
|
|
121
|
+
|
|
122
|
+
exclude = registry_host_ports(list_containers(Path(aisc_root)))
|
|
123
|
+
except Exception:
|
|
124
|
+
exclude = set()
|
|
125
|
+
web_gateway_port = allocate_gateway_host_port(exclude=exclude)
|
|
126
|
+
|
|
127
|
+
# runtime-lifecycle-ux 3a: one-shot runs are project-scoped by definition
|
|
128
|
+
# (agent state mounts from the data root) — they mount the persistent
|
|
129
|
+
# toolchain exactly like managed runtimes (host_bind backend).
|
|
130
|
+
toolchain_root = ""
|
|
131
|
+
if ws_state_dir is not None:
|
|
132
|
+
from aisc.application.toolchain import prepare_toolchain, toolchain_dir_for
|
|
133
|
+
|
|
134
|
+
prepare_toolchain(ws_state_dir)
|
|
135
|
+
toolchain_root = str(toolchain_dir_for(ws_state_dir)).replace("\\", "/")
|
|
136
|
+
|
|
137
|
+
return RunPlan(
|
|
138
|
+
image=image,
|
|
139
|
+
workspace=str(ws_path),
|
|
140
|
+
name=_generate_container_name(name),
|
|
141
|
+
network=network,
|
|
142
|
+
dry_run=dry_run,
|
|
143
|
+
interactive=interactive,
|
|
144
|
+
non_interactive=non_interactive,
|
|
145
|
+
proxy_config=resolved_proxy,
|
|
146
|
+
label=label,
|
|
147
|
+
keep_alive=keep_alive,
|
|
148
|
+
agent_state_root=str(ws_state_dir),
|
|
149
|
+
web_gateway_host_port=web_gateway_port,
|
|
150
|
+
toolchain_root=toolchain_root,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Run result (structured outcome)
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _name_series(container_name: str) -> str:
|
|
159
|
+
"""Container names are ``<alias>-<hash8>``; the alias may itself contain
|
|
160
|
+
dashes, the hash never does — strip the last segment to get the series
|
|
161
|
+
(rename detection keys off it, r2 #C)."""
|
|
162
|
+
return container_name.rsplit("-", 1)[0]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class RunResult:
|
|
167
|
+
"""Result of a run execution — all fields populated even on failure."""
|
|
168
|
+
image: str = ""
|
|
169
|
+
container_id: Optional[str] = None
|
|
170
|
+
docker_argv: List[str] = field(default_factory=list)
|
|
171
|
+
container_exit_code: Optional[int] = None
|
|
172
|
+
dry_run: bool = False
|
|
173
|
+
executed: bool = False
|
|
174
|
+
# svc-5: service-access metadata — the gateway publish that rode this
|
|
175
|
+
# run's docker argv. Empty when no port was allocated.
|
|
176
|
+
web_gateway: Dict[str, Any] = field(default_factory=dict)
|
|
177
|
+
# r1 #2: older same-workspace containers this activation swept
|
|
178
|
+
# (stopped+removed+unregistered before the new one started).
|
|
179
|
+
replaced: List[str] = field(default_factory=list)
|
|
180
|
+
# r1 #2 (revised): a LIVE same-workspace container was REUSED — nothing
|
|
181
|
+
# was started; the summary tells the user it is already open.
|
|
182
|
+
reused: Optional[str] = None
|
|
183
|
+
|
|
184
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
185
|
+
out = {
|
|
186
|
+
"image": self.image,
|
|
187
|
+
"container_id": self.container_id,
|
|
188
|
+
"dry_run": self.dry_run,
|
|
189
|
+
"executed": self.executed,
|
|
190
|
+
"docker_argv": list(self.docker_argv),
|
|
191
|
+
"container_exit_code": self.container_exit_code,
|
|
192
|
+
}
|
|
193
|
+
if self.web_gateway:
|
|
194
|
+
out["web_gateway"] = dict(self.web_gateway)
|
|
195
|
+
if self.replaced:
|
|
196
|
+
out["replaced"] = list(self.replaced)
|
|
197
|
+
if self.reused:
|
|
198
|
+
out["reused"] = self.reused
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _run_captured_with_publish_retry(executor, plan: RunPlan, attempts: int = 3):
|
|
203
|
+
"""``docker run`` (captured) with bounded gateway bind-conflict retry.
|
|
204
|
+
|
|
205
|
+
The argv is regenerated from the (replaced) plan each attempt so the
|
|
206
|
+
publish spec never stacks. Only the captured paths can detect the
|
|
207
|
+
conflict — the interactive streaming path inherits stderr and surfaces
|
|
208
|
+
Docker's own message on the terminal (unchanged behavior).
|
|
209
|
+
"""
|
|
210
|
+
import dataclasses
|
|
211
|
+
|
|
212
|
+
from aisc.application.web_gateway import allocate_gateway_host_port, is_bind_conflict
|
|
213
|
+
|
|
214
|
+
proc = executor.run_captured(list(plan.docker_argv))
|
|
215
|
+
tried = 1
|
|
216
|
+
while proc.exit_code != 0 and is_bind_conflict(proc.stderr or "") \
|
|
217
|
+
and tried < attempts:
|
|
218
|
+
plan = dataclasses.replace(
|
|
219
|
+
plan,
|
|
220
|
+
web_gateway_host_port=allocate_gateway_host_port(
|
|
221
|
+
start_hint=plan.web_gateway_host_port + 1),
|
|
222
|
+
)
|
|
223
|
+
proc = executor.run_captured(list(plan.docker_argv))
|
|
224
|
+
tried += 1
|
|
225
|
+
return proc, plan
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Execute the run
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def run_container(
|
|
233
|
+
plan: RunPlan,
|
|
234
|
+
*,
|
|
235
|
+
emitter: Optional[JsonlEmitter] = None,
|
|
236
|
+
executor: Optional[DockerExecutor] = None,
|
|
237
|
+
capture: bool = False,
|
|
238
|
+
aisc_root: Optional[Path] = None,
|
|
239
|
+
) -> RunResult:
|
|
240
|
+
"""Execute or simulate a ``docker run``, emitting events as needed.
|
|
241
|
+
|
|
242
|
+
All docker operations go through *executor* (RealDockerExecutor by default).
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
plan: The immutable run plan.
|
|
246
|
+
emitter: JsonlEmitter for --events mode, or None.
|
|
247
|
+
executor: DockerExecutor for injection (testing).
|
|
248
|
+
capture: Force captured output (stdout/stderr forwarded to stderr).
|
|
249
|
+
When True, container output never reaches stdout.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
RunResult with outcome data.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
CliError: with ``data`` field carrying full RunResult.to_dict().
|
|
256
|
+
"""
|
|
257
|
+
exec_ = executor or RealDockerExecutor()
|
|
258
|
+
|
|
259
|
+
result = RunResult(
|
|
260
|
+
image=plan.image,
|
|
261
|
+
docker_argv=list(plan.docker_argv),
|
|
262
|
+
dry_run=plan.dry_run,
|
|
263
|
+
executed=False,
|
|
264
|
+
)
|
|
265
|
+
if plan.web_gateway_host_port:
|
|
266
|
+
from aisc.domain.web_services import WEB_GATEWAY_CONTAINER_PORT
|
|
267
|
+
|
|
268
|
+
result.web_gateway = {
|
|
269
|
+
"container_port": WEB_GATEWAY_CONTAINER_PORT,
|
|
270
|
+
"host_port": plan.web_gateway_host_port,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# --- emit start event ---
|
|
274
|
+
if emitter is not None:
|
|
275
|
+
emitter.emit("run.start", data={
|
|
276
|
+
"image": plan.image,
|
|
277
|
+
"workspace": plan.workspace,
|
|
278
|
+
"name": plan.name,
|
|
279
|
+
"network": plan.network,
|
|
280
|
+
"docker_argv": result.docker_argv,
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
# --- validate workspace (both dry-run and real) ---
|
|
284
|
+
try:
|
|
285
|
+
validate_run_resources(Path(plan.workspace))
|
|
286
|
+
except (FileNotFoundError, PermissionError, NotADirectoryError) as exc:
|
|
287
|
+
raise CliError(message=str(exc), exit_code=9,
|
|
288
|
+
error_code="AISC_ERR_PERMISSION_DENIED",
|
|
289
|
+
data=result.to_dict()) from exc
|
|
290
|
+
|
|
291
|
+
# --- validate proxy config (skip file checks in dry-run) ---
|
|
292
|
+
if plan.network == "proxy":
|
|
293
|
+
if not plan.proxy_config:
|
|
294
|
+
raise CliError(
|
|
295
|
+
message="Proxy network mode requires proxy configuration. "
|
|
296
|
+
"Set --aisc-root or ensure .claude/mihomo/config.yaml exists.",
|
|
297
|
+
exit_code=1, error_code="AISC_ERR_GENERAL",
|
|
298
|
+
data=result.to_dict(),
|
|
299
|
+
)
|
|
300
|
+
if not plan.dry_run:
|
|
301
|
+
try:
|
|
302
|
+
validate_proxy_config(Path(plan.proxy_config))
|
|
303
|
+
except (FileNotFoundError, PermissionError) as exc:
|
|
304
|
+
raise CliError(message=str(exc), exit_code=1,
|
|
305
|
+
error_code="AISC_ERR_GENERAL",
|
|
306
|
+
data=result.to_dict()) from exc
|
|
307
|
+
|
|
308
|
+
# --- dry-run: zero docker calls, validated locally ---
|
|
309
|
+
if plan.dry_run:
|
|
310
|
+
if emitter is not None:
|
|
311
|
+
emitter.emit("run.plan", data={
|
|
312
|
+
"docker_argv": result.docker_argv,
|
|
313
|
+
"dry_run": True,
|
|
314
|
+
})
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
# --- preflight ---
|
|
318
|
+
pf = exec_.preflight()
|
|
319
|
+
if not pf.available:
|
|
320
|
+
raise CliError(
|
|
321
|
+
message=f"Docker unavailable: {pf.reason}",
|
|
322
|
+
exit_code=pf.exit_code, error_code=pf.error_code,
|
|
323
|
+
data=result.to_dict(),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# --- image inspect (structured result) ---
|
|
327
|
+
inspect = exec_.inspect_image(plan.image)
|
|
328
|
+
if inspect.status == ImageInspectStatus.MISSING:
|
|
329
|
+
raise CliError(
|
|
330
|
+
message=f"镜像 '{plan.image}' 不存在。请先构建:\n"
|
|
331
|
+
f" aisc build --tag {plan.image}\n"
|
|
332
|
+
f"或用 --image <名称> 指定已有镜像。",
|
|
333
|
+
exit_code=5, error_code="AISC_ERR_IMAGE_NOT_FOUND",
|
|
334
|
+
data=result.to_dict(),
|
|
335
|
+
)
|
|
336
|
+
elif inspect.status == ImageInspectStatus.DOCKER_UNAVAILABLE:
|
|
337
|
+
raise CliError(
|
|
338
|
+
message=inspect.message, exit_code=3,
|
|
339
|
+
error_code="AISC_ERR_DOCKER_UNAVAILABLE",
|
|
340
|
+
data=result.to_dict(),
|
|
341
|
+
)
|
|
342
|
+
elif inspect.status == ImageInspectStatus.PERMISSION_DENIED:
|
|
343
|
+
raise CliError(
|
|
344
|
+
message=inspect.message, exit_code=9,
|
|
345
|
+
error_code="AISC_ERR_PERMISSION_DENIED",
|
|
346
|
+
data=result.to_dict(),
|
|
347
|
+
)
|
|
348
|
+
elif inspect.status in (ImageInspectStatus.TIMEOUT, ImageInspectStatus.ERROR):
|
|
349
|
+
raise CliError(
|
|
350
|
+
message=inspect.message or "Image inspect failed",
|
|
351
|
+
exit_code=1, error_code="AISC_ERR_GENERAL",
|
|
352
|
+
data=result.to_dict(),
|
|
353
|
+
)
|
|
354
|
+
# EXISTS → proceed
|
|
355
|
+
|
|
356
|
+
# --- r1 #2 (revised) + r2 #C: same-workspace activation is IDEMPOTENT;
|
|
357
|
+
# a DIFFERENT --name is a rename intent and rebuilds ---
|
|
358
|
+
# A default (label-less) activation owns the workspace's single CLI
|
|
359
|
+
# slot. A LIVE older container of the SAME name series is REUSED — the
|
|
360
|
+
# user chose to run a workspace that is already open, so tell them
|
|
361
|
+
# (and how to reach or rebuild it) instead of churning containers under
|
|
362
|
+
# them. A live container of a DIFFERENT series (explicit --name that
|
|
363
|
+
# disagrees with the existing one) is a rename: rebuild under the new
|
|
364
|
+
# name (alias and container name must stay one story). Only DEAD ones
|
|
365
|
+
# (Exited/gone — nobody is using them) are swept (stop+rm+unregister)
|
|
366
|
+
# before the new start. GUI runtimes (owner=workbench, lease-guarded)
|
|
367
|
+
# and --label multi-container slots are never touched. If docker does
|
|
368
|
+
# not answer the liveness probe, judge nothing: fall through to the
|
|
369
|
+
# normal start path rather than risk tearing down a live container we
|
|
370
|
+
# could not see.
|
|
371
|
+
if not plan.dry_run and not plan.label:
|
|
372
|
+
from aisc.adapters.container_registry import list_containers as _lc
|
|
373
|
+
from aisc.adapters.container_registry import unregister
|
|
374
|
+
from aisc.application.data_root import workspace_state_dir
|
|
375
|
+
try:
|
|
376
|
+
_reg = workspace_state_dir(Path(plan.workspace))
|
|
377
|
+
_target = Path(plan.workspace)
|
|
378
|
+
_ps = exec_.run_captured(
|
|
379
|
+
["ps", "-a", "--format", "{{.Names}}\t{{.Status}}"],
|
|
380
|
+
timeout=10.0)
|
|
381
|
+
if _ps.exit_code == 0:
|
|
382
|
+
_states: Dict[str, str] = {}
|
|
383
|
+
for _line in (_ps.stdout or "").splitlines():
|
|
384
|
+
_nm2, _, _st = _line.partition("\t")
|
|
385
|
+
if _nm2:
|
|
386
|
+
_states[_nm2] = _st
|
|
387
|
+
for _nm, _meta in _lc(_reg).items():
|
|
388
|
+
_meta = _meta if isinstance(_meta, dict) else {}
|
|
389
|
+
if _meta.get("owner") == "workbench":
|
|
390
|
+
continue
|
|
391
|
+
if _meta.get("label"):
|
|
392
|
+
continue
|
|
393
|
+
try:
|
|
394
|
+
if Path(str(_meta.get("workspace", ""))).resolve() != _target:
|
|
395
|
+
continue
|
|
396
|
+
except OSError:
|
|
397
|
+
continue
|
|
398
|
+
if (_states.get(str(_nm), "").startswith("Up")
|
|
399
|
+
and _name_series(str(_nm)) == _name_series(plan.name)):
|
|
400
|
+
if result.reused is None:
|
|
401
|
+
result.reused = str(_nm)
|
|
402
|
+
continue
|
|
403
|
+
# dead, unknown-to-docker, or a different name series
|
|
404
|
+
# (rename intent) → sweep and rebuild
|
|
405
|
+
exec_.run_captured(["stop", _nm], timeout=30.0)
|
|
406
|
+
_rm = exec_.run_captured(["rm", "-f", _nm], timeout=30.0)
|
|
407
|
+
if _rm.exit_code == 0 or "no such" in (_rm.stderr or "").lower():
|
|
408
|
+
try:
|
|
409
|
+
unregister(_reg, _nm)
|
|
410
|
+
except Exception:
|
|
411
|
+
pass
|
|
412
|
+
result.replaced.append(str(_nm))
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
if result.reused is not None:
|
|
417
|
+
result.executed = False
|
|
418
|
+
return result
|
|
419
|
+
|
|
420
|
+
# --- register container in the multi-container index ---
|
|
421
|
+
# F2-C: the registry root is the WORKSPACE's state dir (workspaces/<h>/
|
|
422
|
+
# runtime) — the same anchor `aisc runtime start` (the Workbench path)
|
|
423
|
+
# uses. The old cwd/install-root anchor split-brained discovery: from
|
|
424
|
+
# $HOME the data-root/workspace overlap gate fired outright (nas).
|
|
425
|
+
from aisc.adapters.container_registry import register
|
|
426
|
+
from aisc.application.data_root import workspace_state_dir
|
|
427
|
+
try:
|
|
428
|
+
reg_root = workspace_state_dir(Path(plan.workspace))
|
|
429
|
+
register(reg_root, plan.name, {
|
|
430
|
+
"image": plan.image,
|
|
431
|
+
"workspace": plan.workspace,
|
|
432
|
+
"network": plan.network,
|
|
433
|
+
"label": plan.label,
|
|
434
|
+
}, set_default=not plan.label)
|
|
435
|
+
# r2 #A: a --label slot is a BYPASS activation — it must not steal
|
|
436
|
+
# the workspace's default pointer (the bare `aisc claude` entry);
|
|
437
|
+
# only the default (label-less) activation owns that pointer.
|
|
438
|
+
except (ValueError, OSError) as exc:
|
|
439
|
+
raise CliError(
|
|
440
|
+
message=f"Failed to write container registry: {exc}",
|
|
441
|
+
exit_code=1, error_code="AISC_ERR_STATE_WRITE_FAILED",
|
|
442
|
+
data=result.to_dict(),
|
|
443
|
+
) from exc
|
|
444
|
+
|
|
445
|
+
# --- plan event for non-dry ---
|
|
446
|
+
if emitter is not None:
|
|
447
|
+
emitter.emit("run.plan", data={
|
|
448
|
+
"docker_argv": result.docker_argv,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
# --- execute ---
|
|
452
|
+
argv = list(plan.docker_argv)
|
|
453
|
+
|
|
454
|
+
if emitter is not None:
|
|
455
|
+
emitter.emit("run.container.start", data={
|
|
456
|
+
"image": plan.image, "name": plan.name,
|
|
457
|
+
"docker_argv": argv,
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
# F2-C (fix2-design.md): run is ACTIVATE-ONLY — detached start (the
|
|
461
|
+
# plan always carries -d for keep-alive runs), capture the container id,
|
|
462
|
+
# return. No attach, no foreground streaming: the interactive surface is
|
|
463
|
+
# `aisc shell` / `aisc claude` / `aisc codex`.
|
|
464
|
+
proc, plan = _run_captured_with_publish_retry(exec_, plan)
|
|
465
|
+
result.docker_argv = list(plan.docker_argv)
|
|
466
|
+
if plan.web_gateway_host_port:
|
|
467
|
+
result.web_gateway = {**result.web_gateway,
|
|
468
|
+
"host_port": plan.web_gateway_host_port}
|
|
469
|
+
result.container_id = (proc.stdout or "").strip() or None
|
|
470
|
+
result.container_exit_code = proc.exit_code
|
|
471
|
+
result.executed = True
|
|
472
|
+
|
|
473
|
+
if proc.exit_code != 0:
|
|
474
|
+
raise CliError(
|
|
475
|
+
message=f"Failed to start container: {proc.stderr}",
|
|
476
|
+
exit_code=10, error_code="AISC_ERR_CONTAINER_FAILED",
|
|
477
|
+
data=result.to_dict(),
|
|
478
|
+
)
|
|
479
|
+
if emitter is not None:
|
|
480
|
+
emitter.emit("run.container.complete", data={
|
|
481
|
+
"exit_code": result.container_exit_code,
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
# svc-5: a `--rm` container is gone the moment it exits — prune its
|
|
485
|
+
# registry entry now so nothing lingers (best-effort, never fatal).
|
|
486
|
+
if not plan.keep_alive and aisc_root is not None:
|
|
487
|
+
try:
|
|
488
|
+
from aisc.adapters.container_registry import gc as registry_gc
|
|
489
|
+
|
|
490
|
+
registry_gc(Path(aisc_root), exec_)
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
return result
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""F2-C (fix2-design.md): the CLI-side activation history — ``cli-runs.json``.
|
|
2
|
+
|
|
3
|
+
Distinct from the Workbench's ``history.json`` (that one carries tab layouts
|
|
4
|
+
and GUI semantics); this is the light, CLI-owned record of `aisc run`
|
|
5
|
+
activations: ABSOLUTE workspace paths + the config snapshot a resume needs.
|
|
6
|
+
Cap-bounded, newest-first.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from aisc.domain.models import CliError
|
|
17
|
+
|
|
18
|
+
#: Keep the history small — it is a picker, not an archive.
|
|
19
|
+
HISTORY_CAP = 20
|
|
20
|
+
|
|
21
|
+
_SCHEMA = {"schema_version": 1}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _history_path() -> Path:
|
|
25
|
+
"""The machine-global history file under <data root>/config/ — resolved
|
|
26
|
+
via the workspace-independent shared-root API (anchoring on cwd would
|
|
27
|
+
false-positive the data-root/workspace overlap gate from $HOME)."""
|
|
28
|
+
from aisc.application.data_root import DataRootResolver
|
|
29
|
+
|
|
30
|
+
root = DataRootResolver().resolve_shared_root()
|
|
31
|
+
config = root / "config"
|
|
32
|
+
config.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return config / "cli-runs.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load(path: Path) -> Dict[str, Any]:
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
39
|
+
if isinstance(data, dict) and isinstance(data.get("runs"), list):
|
|
40
|
+
return data
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
pass
|
|
43
|
+
return {**_SCHEMA, "runs": []}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _save(path: Path, data: Dict[str, Any]) -> None:
|
|
47
|
+
path.write_text(
|
|
48
|
+
json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def record(workspace: str, image: str, network: str, label: str,
|
|
53
|
+
alias: str = "") -> None:
|
|
54
|
+
"""Upsert one activation (absolute path key); newest first, capped.
|
|
55
|
+
|
|
56
|
+
*alias* is the user's `--name` for the workspace — the resume key when
|
|
57
|
+
set (an empty alias means "resume by index or path only")."""
|
|
58
|
+
path = _history_path()
|
|
59
|
+
data = _load(path)
|
|
60
|
+
ws = str(Path(workspace))
|
|
61
|
+
runs: List[Dict[str, Any]] = [r for r in data["runs"] if r.get("path") != ws]
|
|
62
|
+
runs.insert(0, {
|
|
63
|
+
"path": ws,
|
|
64
|
+
"image": image,
|
|
65
|
+
"network": network,
|
|
66
|
+
"label": label,
|
|
67
|
+
"alias": alias,
|
|
68
|
+
"last_used_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
69
|
+
})
|
|
70
|
+
data["runs"] = runs[:HISTORY_CAP]
|
|
71
|
+
_save(path, data)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_active(workspace: str) -> None:
|
|
75
|
+
"""Record the machine-global ACTIVE workspace (the default target for
|
|
76
|
+
`aisc claude`/`codex`/`stop`). Written by every activation."""
|
|
77
|
+
path = _history_path().parent / "cli-active.json"
|
|
78
|
+
path.write_text(json.dumps({"workspace": str(Path(workspace))},
|
|
79
|
+
ensure_ascii=False), encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_active() -> Optional[str]:
|
|
83
|
+
path = _history_path().parent / "cli-active.json"
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
86
|
+
ws = data.get("workspace")
|
|
87
|
+
return str(ws) if isinstance(ws, str) and ws else None
|
|
88
|
+
except (OSError, ValueError):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def list_runs() -> List[Dict[str, Any]]:
|
|
93
|
+
return list(_load(_history_path())["runs"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_resume(spec: str) -> Optional[Dict[str, Any]]:
|
|
97
|
+
"""Resolve a resume target: exact ALIAS → 1-based index (newest = 1) →
|
|
98
|
+
exact path. The alias is the strongest key (unique by construction — a
|
|
99
|
+
second registration with the same alias replaces the first)."""
|
|
100
|
+
runs = list_runs()
|
|
101
|
+
spec = spec.strip()
|
|
102
|
+
for r in runs:
|
|
103
|
+
if spec and r.get("alias") == spec:
|
|
104
|
+
return r
|
|
105
|
+
if spec.isdigit():
|
|
106
|
+
idx = int(spec)
|
|
107
|
+
if 1 <= idx <= len(runs):
|
|
108
|
+
return runs[idx - 1]
|
|
109
|
+
return None
|
|
110
|
+
target = str(Path(spec))
|
|
111
|
+
for r in runs:
|
|
112
|
+
if r.get("path") == target:
|
|
113
|
+
return r
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def require_resume(spec: str) -> Dict[str, Any]:
|
|
118
|
+
rec = resolve_resume(spec)
|
|
119
|
+
if rec is None:
|
|
120
|
+
raise CliError(
|
|
121
|
+
message=f"没有匹配 {spec!r} 的激活记录——用 aisc runs 查看历史"
|
|
122
|
+
f"(别名是最稳的键,序号会随列表变动)",
|
|
123
|
+
exit_code=2, error_code="AISC_ERR_USAGE",
|
|
124
|
+
)
|
|
125
|
+
return rec
|