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
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"""``aisc status | stop | restart | shell | switch`` — container lifecycle commands.
|
|
2
|
+
|
|
3
|
+
All container operations go through an injected ``DockerExecutor``.
|
|
4
|
+
Container discovery: ``--name`` override → state file → structured error.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from aisc.adapters.container_registry import resolve_target
|
|
14
|
+
from aisc.adapters.docker_ import DockerExecutor, RealDockerExecutor
|
|
15
|
+
from aisc.domain.models import CliError, ProcessResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Shared helper — classify process errors
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
def _classify_process_error(proc: ProcessResult, name: str, action: str) -> CliError:
|
|
23
|
+
"""Map a failed ProcessResult to a controlled CliError with stable codes."""
|
|
24
|
+
if proc.command_not_found:
|
|
25
|
+
return CliError(
|
|
26
|
+
message="Docker CLI not found",
|
|
27
|
+
exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE",
|
|
28
|
+
)
|
|
29
|
+
if proc.timed_out:
|
|
30
|
+
return CliError(
|
|
31
|
+
message=f"docker {action} timed out for container '{name}'",
|
|
32
|
+
exit_code=1, error_code="AISC_ERR_GENERAL",
|
|
33
|
+
)
|
|
34
|
+
stderr_lower = (proc.stderr or "").lower()
|
|
35
|
+
if "permission denied" in stderr_lower:
|
|
36
|
+
return CliError(
|
|
37
|
+
message=f"Permission denied accessing container '{name}'",
|
|
38
|
+
exit_code=9, error_code="AISC_ERR_PERMISSION_DENIED",
|
|
39
|
+
)
|
|
40
|
+
if any(kw in stderr_lower for kw in (
|
|
41
|
+
"cannot connect", "is the docker daemon running",
|
|
42
|
+
"connection refused", "error during connect",
|
|
43
|
+
)):
|
|
44
|
+
return CliError(
|
|
45
|
+
message="Docker daemon unreachable",
|
|
46
|
+
exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE",
|
|
47
|
+
)
|
|
48
|
+
return CliError(
|
|
49
|
+
message=f"docker {action} failed for container '{name}' (exit {proc.exit_code}): "
|
|
50
|
+
f"{(proc.stderr or '').strip()[:200]}",
|
|
51
|
+
exit_code=1, error_code="AISC_ERR_GENERAL",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Container discovery
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Container discovery — delegates to container_registry.resolve_target
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def discover_container(
|
|
64
|
+
name_override: Optional[str] = None,
|
|
65
|
+
explicit_root: Optional[str] = None,
|
|
66
|
+
label_override: Optional[str] = None,
|
|
67
|
+
executor: Optional[DockerExecutor] = None,
|
|
68
|
+
) -> str:
|
|
69
|
+
"""Discover the target container name via the registry.
|
|
70
|
+
|
|
71
|
+
Priority (handled in :func:`container_registry.resolve_target`):
|
|
72
|
+
1. ``name_override``
|
|
73
|
+
2. ``label_override`` (unique match)
|
|
74
|
+
3. ``default`` pointer (last ``run``)
|
|
75
|
+
4. single registered container
|
|
76
|
+
5. multiple → ``CliError`` listing candidates
|
|
77
|
+
|
|
78
|
+
A lazy GC prunes registry entries whose container no longer exists.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Container name string.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
CliError: If no container can be discovered.
|
|
85
|
+
"""
|
|
86
|
+
return resolve_target(
|
|
87
|
+
root=None,
|
|
88
|
+
name_override=name_override,
|
|
89
|
+
label_override=label_override,
|
|
90
|
+
executor=executor,
|
|
91
|
+
explicit_root=explicit_root,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Status result
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class StatusResult:
|
|
101
|
+
"""Structured result of container status query."""
|
|
102
|
+
name: str = ""
|
|
103
|
+
exists: bool = False
|
|
104
|
+
running: bool = False
|
|
105
|
+
status: str = ""
|
|
106
|
+
image: str = ""
|
|
107
|
+
container_id: str = ""
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
110
|
+
return {
|
|
111
|
+
"name": self.name,
|
|
112
|
+
"exists": self.exists,
|
|
113
|
+
"running": self.running,
|
|
114
|
+
"status": self.status,
|
|
115
|
+
"image": self.image,
|
|
116
|
+
"container_id": self.container_id,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Status command
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def cmd_status(
|
|
125
|
+
name_override: Optional[str] = None,
|
|
126
|
+
explicit_root: Optional[str] = None,
|
|
127
|
+
executor: Optional[DockerExecutor] = None,
|
|
128
|
+
label_override: Optional[str] = None,
|
|
129
|
+
) -> StatusResult:
|
|
130
|
+
"""Query container status via ``docker inspect``.
|
|
131
|
+
|
|
132
|
+
A missing container returns ``exists=False`` (success), while daemon /
|
|
133
|
+
permission failures raise CliError.
|
|
134
|
+
"""
|
|
135
|
+
exec_ = executor or RealDockerExecutor()
|
|
136
|
+
root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
|
|
137
|
+
name = discover_container(name_override=name_override,
|
|
138
|
+
explicit_root=root,
|
|
139
|
+
label_override=label_override,
|
|
140
|
+
executor=exec_)
|
|
141
|
+
|
|
142
|
+
# Use docker inspect with Go template for structured output
|
|
143
|
+
fmt = '{{.Name}}\t{{.State.Running}}\t{{.State.Status}}\t{{.Config.Image}}\t{{.Id}}'
|
|
144
|
+
argv = ["inspect", "--format", fmt, name]
|
|
145
|
+
|
|
146
|
+
proc = exec_.run_captured(argv, timeout=10.0)
|
|
147
|
+
|
|
148
|
+
if proc.command_not_found or proc.timed_out:
|
|
149
|
+
raise _classify_process_error(proc, name, "inspect")
|
|
150
|
+
|
|
151
|
+
stderr_lower = (proc.stderr or "").lower()
|
|
152
|
+
if proc.exit_code != 0:
|
|
153
|
+
# "No such object" / "not found" → container doesn't exist (success)
|
|
154
|
+
if any(kw in stderr_lower for kw in (
|
|
155
|
+
"no such object", "no such container", "not found",
|
|
156
|
+
)):
|
|
157
|
+
return StatusResult(name=name, exists=False)
|
|
158
|
+
# Delegate other errors to classifier
|
|
159
|
+
raise _classify_process_error(proc, name, "inspect")
|
|
160
|
+
|
|
161
|
+
# Parse tab-separated output
|
|
162
|
+
stdout = proc.stdout.strip()
|
|
163
|
+
if not stdout:
|
|
164
|
+
return StatusResult(name=name, exists=False)
|
|
165
|
+
|
|
166
|
+
parts = stdout.split("\t")
|
|
167
|
+
running = parts[1].lower() == "true" if len(parts) > 1 else False
|
|
168
|
+
status = parts[2] if len(parts) > 2 else ""
|
|
169
|
+
image = parts[3] if len(parts) > 3 else ""
|
|
170
|
+
cid = parts[4][:12] if len(parts) > 4 else ""
|
|
171
|
+
|
|
172
|
+
return StatusResult(
|
|
173
|
+
name=name,
|
|
174
|
+
exists=True,
|
|
175
|
+
running=running,
|
|
176
|
+
status=status,
|
|
177
|
+
image=image,
|
|
178
|
+
container_id=cid,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def print_status_text(result: StatusResult) -> None:
|
|
183
|
+
"""Print status in human-readable format."""
|
|
184
|
+
if not result.exists:
|
|
185
|
+
print(f"Container '{result.name}' not found.")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
running_str = "running" if result.running else "stopped"
|
|
189
|
+
print(f"Container: {result.name}")
|
|
190
|
+
print(f"Exists: yes")
|
|
191
|
+
print(f"Running: {running_str}")
|
|
192
|
+
print(f"Status: {result.status}")
|
|
193
|
+
print(f"Image: {result.image}")
|
|
194
|
+
if result.container_id:
|
|
195
|
+
print(f"ID: {result.container_id}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Stop command
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def cmd_stop(
|
|
203
|
+
name_override: Optional[str] = None,
|
|
204
|
+
explicit_root: Optional[str] = None,
|
|
205
|
+
executor: Optional[DockerExecutor] = None,
|
|
206
|
+
label_override: Optional[str] = None,
|
|
207
|
+
remove: bool = True,
|
|
208
|
+
) -> Dict[str, Any]:
|
|
209
|
+
"""Stop the discovered container via ``docker stop`` (F2-C: then REMOVE).
|
|
210
|
+
|
|
211
|
+
Requires the container to exist. Idempotent: stopping an already-stopped
|
|
212
|
+
container returns success. The container is unregistered from the index
|
|
213
|
+
after stop (it is no longer an active target) and — since F2-C runs are
|
|
214
|
+
detached keep-alives — removed by default, else every stop litters.
|
|
215
|
+
"""
|
|
216
|
+
exec_ = executor or RealDockerExecutor()
|
|
217
|
+
root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
|
|
218
|
+
name = discover_container(name_override=name_override,
|
|
219
|
+
explicit_root=root,
|
|
220
|
+
label_override=label_override,
|
|
221
|
+
executor=exec_)
|
|
222
|
+
|
|
223
|
+
# First check if container exists (same derived registry root)
|
|
224
|
+
status = cmd_status(name_override=name, explicit_root=root,
|
|
225
|
+
executor=executor)
|
|
226
|
+
|
|
227
|
+
if not status.exists:
|
|
228
|
+
raise CliError(
|
|
229
|
+
message=f"容器 '{name}' 不存在——无可停止。",
|
|
230
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if status.running:
|
|
234
|
+
proc = exec_.run_captured(["stop", name], timeout=30.0)
|
|
235
|
+
if proc.exit_code != 0:
|
|
236
|
+
raise _classify_process_error(proc, name, "stop")
|
|
237
|
+
if remove:
|
|
238
|
+
# An already-stopped keep-alive container still needs its cleanup.
|
|
239
|
+
rm = exec_.run_captured(["rm", "-f", name], timeout=30.0)
|
|
240
|
+
if rm.exit_code != 0:
|
|
241
|
+
raise _classify_process_error(rm, name, "remove")
|
|
242
|
+
|
|
243
|
+
# Unregister from the multi-container index (no longer an active
|
|
244
|
+
# target). The registry root must go through _resolve_root — writing at
|
|
245
|
+
# the raw explicit_root would CREATE an empty containers.json there and
|
|
246
|
+
# shadow the real (.aisc / state-dir) one for every later lookup.
|
|
247
|
+
try:
|
|
248
|
+
from aisc.adapters.container_registry import unregister, _resolve_root
|
|
249
|
+
reg = None
|
|
250
|
+
if root:
|
|
251
|
+
reg = _resolve_root(None, root)
|
|
252
|
+
if reg is None:
|
|
253
|
+
# name/label override legs keep the legacy root resolution
|
|
254
|
+
from aisc.application.resources import locate_aisc_root
|
|
255
|
+
try:
|
|
256
|
+
reg = locate_aisc_root(explicit_root=explicit_root)
|
|
257
|
+
except Exception:
|
|
258
|
+
reg = None
|
|
259
|
+
if reg is not None:
|
|
260
|
+
unregister(reg, name)
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
return {"name": name, "stopped": status.running,
|
|
265
|
+
"already_stopped": not status.running, "removed": remove}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def cmd_stop_all(
|
|
269
|
+
explicit_root: Optional[str] = None,
|
|
270
|
+
executor: Optional[DockerExecutor] = None,
|
|
271
|
+
) -> Dict[str, Any]:
|
|
272
|
+
"""F2-C ``aisc stop --all``: stop + remove every CLI-owned container.
|
|
273
|
+
|
|
274
|
+
Workbench-managed runtimes (owner=workbench) are deliberately untouched —
|
|
275
|
+
the GUI owns their lifecycle (leases, reconcile); the CLI's blast radius
|
|
276
|
+
stays on its own one-shot activations.
|
|
277
|
+
"""
|
|
278
|
+
exec_ = executor or RealDockerExecutor()
|
|
279
|
+
from aisc.adapters.container_registry import list_containers, unregister
|
|
280
|
+
from aisc.application.data_root import DataRootResolver
|
|
281
|
+
|
|
282
|
+
# Every workspace registry under the data root (the family's registries
|
|
283
|
+
# are workspace-scoped; the CLI's blast radius excludes Workbench-owned
|
|
284
|
+
# runtimes wherever they live).
|
|
285
|
+
shared_workspaces = DataRootResolver().resolve_shared_root() / "workspaces"
|
|
286
|
+
stopped: List[Dict[str, Any]] = []
|
|
287
|
+
skipped = 0
|
|
288
|
+
registry_dirs = [
|
|
289
|
+
p / "runtime" for p in shared_workspaces.glob("*") if (p / "runtime" / "containers.json").is_file()
|
|
290
|
+
] if shared_workspaces.is_dir() else []
|
|
291
|
+
for reg_dir in registry_dirs:
|
|
292
|
+
for cname, meta in list_containers(reg_dir).items():
|
|
293
|
+
meta = meta or {}
|
|
294
|
+
if meta.get("owner") == "workbench":
|
|
295
|
+
skipped += 1
|
|
296
|
+
continue
|
|
297
|
+
name = str(cname)
|
|
298
|
+
if not name:
|
|
299
|
+
continue
|
|
300
|
+
stop = exec_.run_captured(["stop", name], timeout=30.0)
|
|
301
|
+
rm = exec_.run_captured(["rm", "-f", name], timeout=30.0)
|
|
302
|
+
try:
|
|
303
|
+
unregister(reg_dir, name)
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
stopped.append({
|
|
307
|
+
"name": name,
|
|
308
|
+
"workspace": meta.get("workspace", ""),
|
|
309
|
+
"exit_code": rm.exit_code if rm.exit_code != 0 else stop.exit_code,
|
|
310
|
+
})
|
|
311
|
+
return {"stopped": stopped, "skipped": skipped}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def print_stop_text(data: Dict[str, Any]) -> None:
|
|
315
|
+
"""Print stop result in human-readable format.
|
|
316
|
+
|
|
317
|
+
F2-C stop = stop + REMOVE (detached keep-alives would otherwise litter);
|
|
318
|
+
the wording says both — English phrasing that only said "stopped" hid
|
|
319
|
+
the removal (manual test r2 note).
|
|
320
|
+
"""
|
|
321
|
+
if data.get("already_stopped"):
|
|
322
|
+
print(f"容器 '{data['name']}' 此前已停止,现已移除。")
|
|
323
|
+
else:
|
|
324
|
+
print(f"容器 '{data['name']}' 已停止并移除。")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# Restart command
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def cmd_restart(
|
|
332
|
+
name_override: Optional[str] = None,
|
|
333
|
+
explicit_root: Optional[str] = None,
|
|
334
|
+
executor: Optional[DockerExecutor] = None,
|
|
335
|
+
label_override: Optional[str] = None,
|
|
336
|
+
) -> Dict[str, Any]:
|
|
337
|
+
"""Restart the discovered container via ``docker restart``.
|
|
338
|
+
|
|
339
|
+
Requires the container to exist.
|
|
340
|
+
"""
|
|
341
|
+
exec_ = executor or RealDockerExecutor()
|
|
342
|
+
root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
|
|
343
|
+
name = discover_container(name_override=name_override,
|
|
344
|
+
explicit_root=root,
|
|
345
|
+
label_override=label_override,
|
|
346
|
+
executor=exec_)
|
|
347
|
+
|
|
348
|
+
# First check if container exists
|
|
349
|
+
status = cmd_status(name_override=name, explicit_root=explicit_root,
|
|
350
|
+
executor=executor)
|
|
351
|
+
|
|
352
|
+
if not status.exists:
|
|
353
|
+
raise CliError(
|
|
354
|
+
message=f"Container '{name}' not found — cannot restart.",
|
|
355
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
argv = ["restart", name]
|
|
359
|
+
proc = exec_.run_captured(argv, timeout=30.0)
|
|
360
|
+
|
|
361
|
+
if proc.exit_code != 0:
|
|
362
|
+
raise _classify_process_error(proc, name, "restart")
|
|
363
|
+
|
|
364
|
+
return {"name": name, "restarted": True}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def print_restart_text(data: Dict[str, Any]) -> None:
|
|
368
|
+
"""Print restart result in human-readable format."""
|
|
369
|
+
print(f"Container '{data['name']}' restarted.")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Shell command
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def _active_registry_root(explicit_root: Optional[str]) -> Optional[str]:
|
|
377
|
+
"""F2-C default leg: the ACTIVE workspace's registry root (never cwd —
|
|
378
|
+
the cwd anchor trips the data-root guard from $HOME and reads the
|
|
379
|
+
wrong registry). Shared by the no-override paths of stop/shell/
|
|
380
|
+
status/restart/switch."""
|
|
381
|
+
if explicit_root:
|
|
382
|
+
return explicit_root
|
|
383
|
+
from aisc.cli.commands import runs as cli_runs
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
active = cli_runs.get_active()
|
|
387
|
+
except Exception:
|
|
388
|
+
active = None
|
|
389
|
+
if active:
|
|
390
|
+
from aisc.application.data_root import workspace_state_dir
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
return str(workspace_state_dir(Path(active)))
|
|
394
|
+
except Exception:
|
|
395
|
+
return None
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def cmd_shell(
|
|
400
|
+
name_override: Optional[str] = None,
|
|
401
|
+
explicit_root: Optional[str] = None,
|
|
402
|
+
executor: Optional[DockerExecutor] = None,
|
|
403
|
+
label_override: Optional[str] = None,
|
|
404
|
+
rest: Optional[List[str]] = None,
|
|
405
|
+
) -> ProcessResult:
|
|
406
|
+
"""Open an interactive shell via ``docker exec -it NAME bash``.
|
|
407
|
+
|
|
408
|
+
r2 #I: args after ``--`` pass through verbatim (``aisc shell -- ls -la``)
|
|
409
|
+
like the agent sugar — a non-interactive one-shot exec (no ``-it``).
|
|
410
|
+
Uses streaming executor for interactive terminal. Text-only.
|
|
411
|
+
Returns ProcessResult so caller can inspect exit_code / errors.
|
|
412
|
+
"""
|
|
413
|
+
exec_ = executor or RealDockerExecutor()
|
|
414
|
+
root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
|
|
415
|
+
name = discover_container(name_override=name_override,
|
|
416
|
+
explicit_root=root,
|
|
417
|
+
label_override=label_override,
|
|
418
|
+
executor=exec_)
|
|
419
|
+
|
|
420
|
+
# Verify container exists and is running
|
|
421
|
+
status = cmd_status(name_override=name, explicit_root=root,
|
|
422
|
+
executor=executor)
|
|
423
|
+
|
|
424
|
+
if not status.exists:
|
|
425
|
+
raise CliError(
|
|
426
|
+
message=f"容器 '{name}' 不存在——无法打开 shell。",
|
|
427
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
428
|
+
)
|
|
429
|
+
if not status.running:
|
|
430
|
+
raise CliError(
|
|
431
|
+
message=f"容器 '{name}' 未在运行——无法打开 shell。",
|
|
432
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
passthrough = [a for a in (rest or []) if a != "--"]
|
|
436
|
+
if passthrough:
|
|
437
|
+
# one-shot, non-interactive: no -it (docker exec -t without a TTY
|
|
438
|
+
# dies), stream like the interactive case
|
|
439
|
+
argv = ["exec", name, *passthrough]
|
|
440
|
+
proc = exec_.run_streaming(argv)
|
|
441
|
+
if proc.command_not_found:
|
|
442
|
+
raise CliError(message="Docker CLI 不可用",
|
|
443
|
+
exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE")
|
|
444
|
+
return proc
|
|
445
|
+
|
|
446
|
+
# v2.1.7 S6 (Gate-S6/D10): interactive shells get the tutorial `help`
|
|
447
|
+
# via the exec environment (BASH_FUNC_* re-import) — see
|
|
448
|
+
# aisc.cli.tutorial.help_function_env. The plain `bash` argv is
|
|
449
|
+
# unchanged; nothing is written to the image or any profile.
|
|
450
|
+
argv = ["exec", "-it", name, "bash"]
|
|
451
|
+
proc = exec_.run_streaming(argv)
|
|
452
|
+
|
|
453
|
+
# Map transport errors — nonzero exit from docker exec is returned to caller
|
|
454
|
+
if proc.command_not_found or proc.timed_out:
|
|
455
|
+
raise _classify_process_error(proc, name, "exec")
|
|
456
|
+
|
|
457
|
+
return proc
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
# Switch command — scope-preserving Bash wrapper
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
#
|
|
464
|
+
# ``docker exec`` starts a new process that does **not** inherit
|
|
465
|
+
# ``entrypoint.sh`` dynamically exports runtime paths for PID 1. This
|
|
466
|
+
# wrapper reads the NUL-delimited /proc/1/environ
|
|
467
|
+
# **without eval** using a ``while IFS= read -r -d ''`` loop and a ``case``
|
|
468
|
+
# statement, so literal special characters (spaces, ``$``, ``;``, quotes,
|
|
469
|
+
# backticks, glob chars) in the values survive exactly.
|
|
470
|
+
#
|
|
471
|
+
# The environ source path is passed as the first positional argument
|
|
472
|
+
# (``$1``). In production this is always ``/proc/1/environ``; tests may
|
|
473
|
+
# substitute a temp file. The wrapper shifts past the source and the
|
|
474
|
+
# ``--`` guard, then ``exec "$@"`` with the target command. The provider
|
|
475
|
+
# / subcommand is always positional — never shell-interpolated.
|
|
476
|
+
#
|
|
477
|
+
# Fail-closed: if the source cannot be read or a required runtime path is
|
|
478
|
+
# absent or empty the wrapper exits 101. No home-directory fallback.
|
|
479
|
+
#
|
|
480
|
+
# .. code-block:: text
|
|
481
|
+
#
|
|
482
|
+
# docker exec -it NAME bash -c '
|
|
483
|
+
# while IFS= read -r -d """" entry; do
|
|
484
|
+
# case "$entry" in
|
|
485
|
+
# CLAUDE_CONFIG_DIR=*) ... ;;
|
|
486
|
+
# CC_SWITCH_CONFIG_DIR=*) ... ;;
|
|
487
|
+
# esac
|
|
488
|
+
# done < "$1"
|
|
489
|
+
# shift; shift # past source, past --
|
|
490
|
+
# [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${CC_SWITCH_CONFIG_DIR:-}" ] || exit 101
|
|
491
|
+
# export CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME
|
|
492
|
+
# exec "$@"
|
|
493
|
+
# ' aisc-scope /proc/1/environ -- cc-switch
|
|
494
|
+
_SCOPE_WRAPPER = "\n".join([
|
|
495
|
+
'if [ ! -r "$1" ]; then',
|
|
496
|
+
" echo 'Error: Cannot read scope environment from PID 1' >&2",
|
|
497
|
+
' exit 101',
|
|
498
|
+
'fi',
|
|
499
|
+
'unset CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME',
|
|
500
|
+
"while IFS= read -r -d '' entry; do",
|
|
501
|
+
' case "$entry" in',
|
|
502
|
+
' CLAUDE_CONFIG_DIR=*) CLAUDE_CONFIG_DIR=${entry#*=} ;;',
|
|
503
|
+
' CC_SWITCH_CONFIG_DIR=*) CC_SWITCH_CONFIG_DIR=${entry#*=} ;;',
|
|
504
|
+
' CODEX_CONFIG_DIR=*) CODEX_CONFIG_DIR=${entry#*=} ;;',
|
|
505
|
+
' CODEX_HOME=*) CODEX_HOME=${entry#*=} ;;',
|
|
506
|
+
' esac',
|
|
507
|
+
'done < "$1"',
|
|
508
|
+
'shift',
|
|
509
|
+
'shift',
|
|
510
|
+
'if [ -z "${CLAUDE_CONFIG_DIR:-}" ] || [ -z "${CC_SWITCH_CONFIG_DIR:-}" ]; then',
|
|
511
|
+
" echo 'Error: Cannot read scope environment from PID 1' >&2",
|
|
512
|
+
' exit 101',
|
|
513
|
+
'fi',
|
|
514
|
+
'export CLAUDE_CONFIG_DIR CC_SWITCH_CONFIG_DIR CODEX_CONFIG_DIR CODEX_HOME',
|
|
515
|
+
'exec "$@"',
|
|
516
|
+
'', # trailing newline
|
|
517
|
+
])
|
|
518
|
+
|
|
519
|
+
# Production environ source — always /proc/1/environ.
|
|
520
|
+
_SCOPE_ENV_SOURCE = "/proc/1/environ"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _build_switch_argv(name: str, quick: Optional[str]) -> list:
|
|
524
|
+
"""Build the ``docker exec`` argv for switch.
|
|
525
|
+
|
|
526
|
+
Wraps the target command in a Bash snippet that reads the AISC and
|
|
527
|
+
cc-switch runtime paths from PID 1's ``_SCOPE_ENV_SOURCE``
|
|
528
|
+
(a NUL-delimited environ file, normally ``/proc/1/environ``).
|
|
529
|
+
|
|
530
|
+
The source path and the ``--`` guard are passed as positional
|
|
531
|
+
arguments. The provider / subcommand follows as positional argv,
|
|
532
|
+
**never** shell-interpolated.
|
|
533
|
+
|
|
534
|
+
Returns a list suitable for ``DockerExecutor.run_streaming``.
|
|
535
|
+
"""
|
|
536
|
+
if quick:
|
|
537
|
+
return ["exec", "-it", name, "bash", "-c", _SCOPE_WRAPPER,
|
|
538
|
+
"aisc-scope", _SCOPE_ENV_SOURCE, "--",
|
|
539
|
+
"cc-switch", "-a", "claude", "provider", "switch", quick]
|
|
540
|
+
else:
|
|
541
|
+
return ["exec", "-it", name, "bash", "-c", _SCOPE_WRAPPER,
|
|
542
|
+
"aisc-scope", _SCOPE_ENV_SOURCE, "--", "cc-switch"]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _validate_provider_id(provider: str) -> None:
|
|
546
|
+
"""Validate that *provider* is a non-empty positional token."""
|
|
547
|
+
if not provider or not provider.strip():
|
|
548
|
+
raise CliError(
|
|
549
|
+
message="Provider name required for --quick switch",
|
|
550
|
+
exit_code=2, error_code="AISC_ERR_USAGE",
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def cmd_switch(
|
|
555
|
+
name_override: Optional[str] = None,
|
|
556
|
+
explicit_root: Optional[str] = None,
|
|
557
|
+
quick: Optional[str] = None,
|
|
558
|
+
executor: Optional[DockerExecutor] = None,
|
|
559
|
+
label_override: Optional[str] = None,
|
|
560
|
+
) -> ProcessResult:
|
|
561
|
+
"""Switch AI provider inside the container.
|
|
562
|
+
|
|
563
|
+
Default (no --quick): ``docker exec -it NAME cc-switch`` (full TUI).
|
|
564
|
+
Quick mode uses ``cc-switch -a claude provider switch PROVIDER``.
|
|
565
|
+
|
|
566
|
+
Uses streaming executor for interactive terminal. Text-only.
|
|
567
|
+
Returns ProcessResult so caller can inspect exit_code / errors.
|
|
568
|
+
"""
|
|
569
|
+
exec_ = executor or RealDockerExecutor()
|
|
570
|
+
|
|
571
|
+
# Validate --quick provider early (before any Docker interaction)
|
|
572
|
+
if quick is not None:
|
|
573
|
+
_validate_provider_id(quick)
|
|
574
|
+
if not quick:
|
|
575
|
+
raise CliError(
|
|
576
|
+
message="Provider name required for --quick switch",
|
|
577
|
+
exit_code=2, error_code="AISC_ERR_USAGE",
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
|
|
581
|
+
name = discover_container(name_override=name_override,
|
|
582
|
+
explicit_root=root,
|
|
583
|
+
label_override=label_override,
|
|
584
|
+
executor=exec_)
|
|
585
|
+
|
|
586
|
+
# Verify container exists and is running
|
|
587
|
+
status = cmd_status(name_override=name, explicit_root=root,
|
|
588
|
+
executor=executor)
|
|
589
|
+
|
|
590
|
+
if not status.exists:
|
|
591
|
+
raise CliError(
|
|
592
|
+
message=f"Container '{name}' not found — cannot switch provider.",
|
|
593
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
594
|
+
)
|
|
595
|
+
if not status.running:
|
|
596
|
+
raise CliError(
|
|
597
|
+
message=f"Container '{name}' is not running — cannot switch provider.",
|
|
598
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Build argv via the scope-preserving wrapper
|
|
602
|
+
argv = _build_switch_argv(name, quick)
|
|
603
|
+
|
|
604
|
+
proc = exec_.run_streaming(argv)
|
|
605
|
+
|
|
606
|
+
if proc.command_not_found or proc.timed_out:
|
|
607
|
+
raise _classify_process_error(proc, name, "exec")
|
|
608
|
+
|
|
609
|
+
return proc
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def cmd_provider_set_key(
|
|
613
|
+
name_override: Optional[str] = None,
|
|
614
|
+
explicit_root: Optional[str] = None,
|
|
615
|
+
provider_id: Optional[str] = None,
|
|
616
|
+
agent: str = "claude",
|
|
617
|
+
executor: Optional[DockerExecutor] = None,
|
|
618
|
+
label_override: Optional[str] = None,
|
|
619
|
+
) -> ProcessResult:
|
|
620
|
+
"""Open cc-switch's interactive editor for a provider.
|
|
621
|
+
|
|
622
|
+
The compatibility-facing command remains ``aisc provider set-key``, but
|
|
623
|
+
credentials are entered only through ``cc-switch provider edit`` so they
|
|
624
|
+
never appear in shell history or process arguments.
|
|
625
|
+
|
|
626
|
+
Uses streaming executor for interactive terminal. Text-only.
|
|
627
|
+
Returns ProcessResult so caller can inspect exit_code / errors.
|
|
628
|
+
"""
|
|
629
|
+
exec_ = executor or RealDockerExecutor()
|
|
630
|
+
|
|
631
|
+
# Validate provider_id
|
|
632
|
+
if not provider_id or not provider_id.strip():
|
|
633
|
+
raise CliError(
|
|
634
|
+
message="Provider ID required",
|
|
635
|
+
exit_code=2, error_code="AISC_ERR_USAGE",
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
root = _active_registry_root(explicit_root) if not (name_override or label_override) else explicit_root
|
|
639
|
+
name = discover_container(name_override=name_override,
|
|
640
|
+
explicit_root=root,
|
|
641
|
+
label_override=label_override,
|
|
642
|
+
executor=exec_)
|
|
643
|
+
|
|
644
|
+
# Verify container exists and is running
|
|
645
|
+
status = cmd_status(name_override=name, explicit_root=root,
|
|
646
|
+
executor=executor)
|
|
647
|
+
|
|
648
|
+
if not status.exists:
|
|
649
|
+
raise CliError(
|
|
650
|
+
message=f"Container '{name}' not found — cannot set API key.",
|
|
651
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
652
|
+
)
|
|
653
|
+
if not status.running:
|
|
654
|
+
raise CliError(
|
|
655
|
+
message=f"Container '{name}' is not running — cannot set API key.",
|
|
656
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
# Build argv via the scope-preserving wrapper
|
|
660
|
+
argv = _build_provider_edit_argv(name, agent, provider_id)
|
|
661
|
+
|
|
662
|
+
proc = exec_.run_streaming(argv)
|
|
663
|
+
|
|
664
|
+
if proc.command_not_found or proc.timed_out:
|
|
665
|
+
raise _classify_process_error(proc, name, "exec")
|
|
666
|
+
|
|
667
|
+
return proc
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _build_provider_edit_argv(
|
|
671
|
+
name: str,
|
|
672
|
+
agent: str,
|
|
673
|
+
provider_id: str,
|
|
674
|
+
) -> list:
|
|
675
|
+
"""Build the ``docker exec`` argv for interactive provider editing.
|
|
676
|
+
|
|
677
|
+
Wraps the target command in the same scope-preserving Bash wrapper
|
|
678
|
+
used by switch. No credential value is accepted by this function.
|
|
679
|
+
|
|
680
|
+
Returns a list suitable for ``DockerExecutor.run_streaming``.
|
|
681
|
+
"""
|
|
682
|
+
return ["exec", "-it", name, "bash", "-c", _SCOPE_WRAPPER,
|
|
683
|
+
"aisc-scope", _SCOPE_ENV_SOURCE, "--",
|
|
684
|
+
"cc-switch", "-a", agent, "provider", "edit", provider_id]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def print_switch_text(data: Dict[str, Any]) -> None:
|
|
688
|
+
"""Print switch result summary."""
|
|
689
|
+
name = data.get("name", "")
|
|
690
|
+
provider = data.get("provider", "")
|
|
691
|
+
quick = data.get("quick", False)
|
|
692
|
+
if quick and provider:
|
|
693
|
+
print(f"Switched container '{name}' to provider '{provider}'.")
|
|
694
|
+
elif provider:
|
|
695
|
+
print(f"Switched container '{name}' to provider '{provider}'.")
|
|
696
|
+
else:
|
|
697
|
+
print(f"Provider switch completed for container '{name}'.")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# ---------------------------------------------------------------------------
|
|
701
|
+
# Ps command — list all registered containers
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
@dataclass
|
|
705
|
+
class PsRow:
|
|
706
|
+
"""One row of the ``aisc ps`` table."""
|
|
707
|
+
name: str = ""
|
|
708
|
+
label: str = ""
|
|
709
|
+
status: str = ""
|
|
710
|
+
running: bool = False
|
|
711
|
+
image: str = ""
|
|
712
|
+
workspace: str = ""
|
|
713
|
+
active: bool = False
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def cmd_ps(
|
|
717
|
+
name_override: Optional[str] = None,
|
|
718
|
+
explicit_root: Optional[str] = None,
|
|
719
|
+
executor: Optional[DockerExecutor] = None,
|
|
720
|
+
) -> List[PsRow]:
|
|
721
|
+
"""List every registered container machine-wide, with live docker status.
|
|
722
|
+
|
|
723
|
+
Manual test r1 #1 (F2-C): activations register under the WORKSPACE's
|
|
724
|
+
state dir (data-root ``workspaces/<h>/runtime``), so the old
|
|
725
|
+
cwd-anchored single registry never saw them — scan every workspace
|
|
726
|
+
registry instead (the same data face as ``aisc workspaces``). One
|
|
727
|
+
``docker ps -a`` round trip carries status; a registered name docker
|
|
728
|
+
does not know reads as ``gone`` (stale entry — orphan hygiene). When
|
|
729
|
+
docker is unreachable nothing is guessed: every row shows ``?``.
|
|
730
|
+
|
|
731
|
+
*explicit_root* now means the DATA ROOT override (a dir containing
|
|
732
|
+
``workspaces/``), matching the registry anchor; the machine-global
|
|
733
|
+
ACTIVE workspace (last ``aisc run``) gets ``active=True``.
|
|
734
|
+
"""
|
|
735
|
+
from aisc.cli.commands.workspaces import docker_states, registry_entries
|
|
736
|
+
|
|
737
|
+
exec_ = executor or RealDockerExecutor()
|
|
738
|
+
|
|
739
|
+
entries, defaults = registry_entries(exec_, data_root=explicit_root)
|
|
740
|
+
states, docker_ok = docker_states(exec_)
|
|
741
|
+
|
|
742
|
+
rows: List[PsRow] = []
|
|
743
|
+
for nm, meta in entries:
|
|
744
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
745
|
+
name = str(nm)
|
|
746
|
+
if not name:
|
|
747
|
+
continue
|
|
748
|
+
if not docker_ok:
|
|
749
|
+
status, running = "?", False
|
|
750
|
+
elif name not in states:
|
|
751
|
+
status, running = "gone", False
|
|
752
|
+
else:
|
|
753
|
+
status = states[name]
|
|
754
|
+
running = status.startswith("Up")
|
|
755
|
+
ws = str(meta.get("workspace", ""))
|
|
756
|
+
rows.append(PsRow(
|
|
757
|
+
name=name,
|
|
758
|
+
label=str(meta.get("label", "")),
|
|
759
|
+
image=str(meta.get("image", "")),
|
|
760
|
+
workspace=ws,
|
|
761
|
+
status=status,
|
|
762
|
+
running=running,
|
|
763
|
+
# r2 #B: star the registry DEFAULT pointer — what a bare
|
|
764
|
+
# `aisc claude` actually lands in — not "any container of the
|
|
765
|
+
# active workspace" (label slots live there too).
|
|
766
|
+
active=name in defaults,
|
|
767
|
+
))
|
|
768
|
+
|
|
769
|
+
# active first, then running, then name — glanceable and stable
|
|
770
|
+
rows.sort(key=lambda r: (not r.active, not r.running, r.name))
|
|
771
|
+
return rows
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def print_ps_text(rows: List[PsRow]) -> None:
|
|
775
|
+
"""Print the ``aisc ps`` table (dead/gone rows get a cleanup hint)."""
|
|
776
|
+
if not rows:
|
|
777
|
+
print("尚无已注册容器——先 aisc run <路径> 激活工作区。")
|
|
778
|
+
return
|
|
779
|
+
print(f"{'NAME':<36} {'ACTIVE':<6} {'LABEL':<10} {'STATUS':<10} {'IMAGE':<24} WORKSPACE")
|
|
780
|
+
for r in rows:
|
|
781
|
+
label = r.label or "-"
|
|
782
|
+
star = "*" if r.active else ""
|
|
783
|
+
print(f"{r.name:<36} {star:<6} {label:<10} {r.status:<10} {r.image:<24} {r.workspace}")
|
|
784
|
+
# r2 #G: orphan hygiene — dead/gone rows exist, tell the user what to do
|
|
785
|
+
if any(not r.running for r in rows):
|
|
786
|
+
print("提示: 已停止(gone/Exited)的容器可 aisc stop --name <名> 清除,"
|
|
787
|
+
"或 aisc run --resume <别名|序号|路径> 重建。")
|