aisc-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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