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,369 @@
|
|
|
1
|
+
"""serve fs.* ops — the remote-authoritative file plane (2.1.10 R3, D-9).
|
|
2
|
+
|
|
3
|
+
Every path is interpreted ON THE MACHINE THE SERVE PROCESS RUNS ON. The
|
|
4
|
+
Workbench never keeps a local copy: browsing, preview and writes all ride
|
|
5
|
+
these ops over the serve channel; watch events ride the reserved ``event``
|
|
6
|
+
frame.
|
|
7
|
+
|
|
8
|
+
Containment: paths are normalized (``..`` resolved) and rejected unless
|
|
9
|
+
inside the op's own root — the F1 browse-pinning lesson, server-side now.
|
|
10
|
+
watchdog is imported lazily; without it ``fs.watch`` degrades to a clean
|
|
11
|
+
"unsupported" error and the Workbench falls back to list polling (D-9).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import posixpath
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from aisc.domain.models import CliError
|
|
23
|
+
|
|
24
|
+
#: Page size aligned with the Workbench's Explorer cursor protocol (Rust
|
|
25
|
+
#: LIST_PAGE = 200).
|
|
26
|
+
LIST_PAGE = 200
|
|
27
|
+
|
|
28
|
+
#: Read budget aligned with the Workbench preview budget (512 KiB).
|
|
29
|
+
READ_BUDGET = 512 * 1024
|
|
30
|
+
|
|
31
|
+
#: Server-side ignore set — the equivalent of the Rust DEFAULT_IGNORE +
|
|
32
|
+
#: temp-file filter, so remote trees look exactly like local ones.
|
|
33
|
+
IGNORE_NAMES = {
|
|
34
|
+
".git", ".aisc", ".claude", ".codex", ".cc-switch", ".local",
|
|
35
|
+
".mcp.json", "node_modules", "target", "build", "dist", "tmp", "temp",
|
|
36
|
+
"__pycache__", ".venv", "venv",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_temp_name(name: str) -> bool:
|
|
41
|
+
lower = name.lower()
|
|
42
|
+
return (
|
|
43
|
+
".tmp." in lower
|
|
44
|
+
or lower.endswith((".tmp", ".temp", "~", ".swp", ".swo", ".aisc-tmp"))
|
|
45
|
+
or lower.startswith((".#", "~$"))
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ignored(name: str) -> bool:
|
|
50
|
+
return name in IGNORE_NAMES or _is_temp_name(name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve(root: str, rel: str) -> str:
|
|
54
|
+
"""Resolve a ROOT-RELATIVE path (the Explorer's relativePath concept)
|
|
55
|
+
to a normalized absolute path inside *root*. Absolute-looking input and
|
|
56
|
+
`..` escapes are rejected — containment is server-side."""
|
|
57
|
+
root = posixpath.normpath(root)
|
|
58
|
+
rel = rel or ""
|
|
59
|
+
if rel.startswith("/") or rel.startswith("~"):
|
|
60
|
+
# Absolute/home paths are NOT rooted — reject before stripping so
|
|
61
|
+
# "/etc/passwd" can never silently become "<root>/etc/passwd".
|
|
62
|
+
raise CliError(message=f"path must be relative to the workspace root: {rel!r}",
|
|
63
|
+
exit_code=2, error_code="AISC_ERR_FS_PATH")
|
|
64
|
+
rel = rel.strip("/")
|
|
65
|
+
if not rel:
|
|
66
|
+
return root
|
|
67
|
+
joined = posixpath.normpath(posixpath.join(root, rel))
|
|
68
|
+
if joined != root and not joined.startswith(root.rstrip("/") + "/"):
|
|
69
|
+
raise CliError(message=f"path escapes the workspace root: {rel!r}",
|
|
70
|
+
exit_code=2, error_code="AISC_ERR_FS_CONTAINMENT")
|
|
71
|
+
return joined
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _rel(root: str, abs_path: str) -> str:
|
|
75
|
+
root = posixpath.normpath(root).rstrip("/")
|
|
76
|
+
rel = abs_path[len(root):].lstrip("/")
|
|
77
|
+
return rel
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# ops (uniform serve signature: (ns, payload, runtime) -> (data, exit, errs))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def op_fs_list(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
|
|
85
|
+
root = str(payload.get("root") or "/")
|
|
86
|
+
target = _resolve(root, str(payload.get("path") or ""))
|
|
87
|
+
if not os.path.isdir(target):
|
|
88
|
+
raise CliError(message=f"not a directory: {payload.get('path') or '/'}",
|
|
89
|
+
exit_code=1, error_code="AISC_ERR_FS_NOT_DIR")
|
|
90
|
+
try:
|
|
91
|
+
names = sorted(os.listdir(target))
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
raise CliError(message=f"listdir failed: {exc}", exit_code=1,
|
|
94
|
+
error_code="AISC_ERR_FS_IO") from exc
|
|
95
|
+
offset = int(payload.get("offset") or 0)
|
|
96
|
+
page = names[offset:offset + LIST_PAGE]
|
|
97
|
+
entries = []
|
|
98
|
+
for name in page:
|
|
99
|
+
if _ignored(name):
|
|
100
|
+
continue
|
|
101
|
+
full = os.path.join(target, name)
|
|
102
|
+
try:
|
|
103
|
+
st = os.stat(full, follow_symlinks=False)
|
|
104
|
+
kind = "dir" if os.path.isdir(full) else "file"
|
|
105
|
+
entries.append({"name": name, "kind": kind, "size": st.st_size,
|
|
106
|
+
"modified": int(st.st_mtime)})
|
|
107
|
+
except OSError:
|
|
108
|
+
entries.append({"name": name, "kind": "file", "size": 0, "modified": 0})
|
|
109
|
+
data: Dict[str, Any] = {"entries": entries}
|
|
110
|
+
if offset + LIST_PAGE < len(names):
|
|
111
|
+
data["nextOffset"] = offset + LIST_PAGE
|
|
112
|
+
return data, 0, []
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def op_fs_read(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
|
|
116
|
+
import base64
|
|
117
|
+
|
|
118
|
+
root = str(payload.get("root") or "/")
|
|
119
|
+
target = _resolve(root, str(payload.get("path") or ""))
|
|
120
|
+
budget = int(payload.get("maxBytes") or READ_BUDGET)
|
|
121
|
+
try:
|
|
122
|
+
st = os.stat(target)
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
raise CliError(message=f"stat failed: {exc}", exit_code=1,
|
|
125
|
+
error_code="AISC_ERR_FS_IO") from exc
|
|
126
|
+
if os.path.isdir(target):
|
|
127
|
+
raise CliError(message="path is a directory", exit_code=1,
|
|
128
|
+
error_code="AISC_ERR_FS_IS_DIR")
|
|
129
|
+
truncated = st.st_size > budget
|
|
130
|
+
with open(target, "rb") as f:
|
|
131
|
+
blob = f.read(budget)
|
|
132
|
+
return {
|
|
133
|
+
"base64": base64.b64encode(blob).decode("ascii"),
|
|
134
|
+
"size": st.st_size,
|
|
135
|
+
"truncated": truncated,
|
|
136
|
+
"modified": int(st.st_mtime),
|
|
137
|
+
}, 0, []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def op_fs_write(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
|
|
141
|
+
import base64
|
|
142
|
+
|
|
143
|
+
root = str(payload.get("root") or "/")
|
|
144
|
+
target = _resolve(root, str(payload.get("path") or ""))
|
|
145
|
+
blob = base64.b64decode(str(payload.get("base64") or ""))
|
|
146
|
+
atomic = _atomic_writer(target, blob)
|
|
147
|
+
if atomic is not None:
|
|
148
|
+
raise atomic
|
|
149
|
+
return {"bytes": len(blob)}, 0, []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _atomic_writer(target: str, blob: bytes) -> Optional[CliError]:
|
|
153
|
+
try:
|
|
154
|
+
tmp = target + ".aisc-tmp"
|
|
155
|
+
with open(tmp, "wb") as f:
|
|
156
|
+
f.write(blob)
|
|
157
|
+
os.replace(tmp, target)
|
|
158
|
+
return None
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
try:
|
|
161
|
+
os.unlink(target + ".aisc-tmp")
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
return CliError(message=f"write failed: {exc}", exit_code=1,
|
|
165
|
+
error_code="AISC_ERR_FS_IO")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def op_fs_mkdir(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
|
|
169
|
+
root = str(payload.get("root") or "/")
|
|
170
|
+
target = _resolve(root, str(payload.get("path") or ""))
|
|
171
|
+
try:
|
|
172
|
+
os.makedirs(target, exist_ok=True)
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise CliError(message=f"mkdir failed: {exc}", exit_code=1,
|
|
175
|
+
error_code="AISC_ERR_FS_IO") from exc
|
|
176
|
+
return {}, 0, []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def op_fs_rename(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
|
|
180
|
+
root = str(payload.get("root") or "/")
|
|
181
|
+
src = _resolve(root, str(payload.get("from") or ""))
|
|
182
|
+
dst = _resolve(root, str(payload.get("to") or ""))
|
|
183
|
+
try:
|
|
184
|
+
os.replace(src, dst)
|
|
185
|
+
except OSError as exc:
|
|
186
|
+
raise CliError(message=f"rename failed: {exc}", exit_code=1,
|
|
187
|
+
error_code="AISC_ERR_FS_IO") from exc
|
|
188
|
+
return {}, 0, []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def op_fs_delete(_ns, payload: Dict[str, Any], _runtime) -> Tuple[Any, int, list]:
|
|
192
|
+
root = str(payload.get("root") or "/")
|
|
193
|
+
target = _resolve(root, str(payload.get("path") or ""))
|
|
194
|
+
if target == posixpath.normpath(root):
|
|
195
|
+
raise CliError(message="refusing to delete the workspace root",
|
|
196
|
+
exit_code=2, error_code="AISC_ERR_FS_CONTAINMENT")
|
|
197
|
+
try:
|
|
198
|
+
if os.path.isdir(target):
|
|
199
|
+
import shutil
|
|
200
|
+
|
|
201
|
+
shutil.rmtree(target)
|
|
202
|
+
else:
|
|
203
|
+
os.unlink(target)
|
|
204
|
+
except OSError as exc:
|
|
205
|
+
raise CliError(message=f"delete failed: {exc}", exit_code=1,
|
|
206
|
+
error_code="AISC_ERR_FS_IO") from exc
|
|
207
|
+
return {}, 0, []
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# watch (watchdog, lazy) — events ride the reserved `event` frame
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class WatchRegistry:
|
|
215
|
+
"""One watchdog observer per serve process; per-root handlers batch
|
|
216
|
+
events (100ms debounce) into a single fs.change frame per burst."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, emit: Callable[[Dict[str, Any]], None]) -> None:
|
|
219
|
+
self._emit_cb = emit
|
|
220
|
+
self._lock = threading.Lock()
|
|
221
|
+
self._observer: Any = None
|
|
222
|
+
self._roots: Dict[str, Any] = {}
|
|
223
|
+
self._pending: Dict[str, Dict[str, Tuple[str, str]]] = {} # root -> {abspath: (change, kind)}
|
|
224
|
+
self._flush_timer: Optional[threading.Timer] = None
|
|
225
|
+
|
|
226
|
+
def _emit(self, frame: Dict[str, Any]) -> None:
|
|
227
|
+
try:
|
|
228
|
+
self._emit_cb(frame)
|
|
229
|
+
except Exception: # noqa: BLE001 — emit must never kill the watcher
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def available(self) -> bool:
|
|
233
|
+
try:
|
|
234
|
+
import watchdog # noqa: F401
|
|
235
|
+
|
|
236
|
+
return True
|
|
237
|
+
except ImportError:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
def watch(self, root: str) -> None:
|
|
241
|
+
try:
|
|
242
|
+
from watchdog.events import FileSystemEventHandler
|
|
243
|
+
from watchdog.observers import Observer
|
|
244
|
+
except ImportError as exc:
|
|
245
|
+
raise CliError(message="fs.watch unsupported: watchdog unavailable",
|
|
246
|
+
exit_code=3, error_code="AISC_ERR_FS_WATCH_UNSUPPORTED") from exc
|
|
247
|
+
root = posixpath.normpath(root)
|
|
248
|
+
if not os.path.isdir(root):
|
|
249
|
+
raise CliError(message=f"not a directory: {root}",
|
|
250
|
+
exit_code=1, error_code="AISC_ERR_FS_NOT_DIR")
|
|
251
|
+
with self._lock:
|
|
252
|
+
if root in self._roots:
|
|
253
|
+
return
|
|
254
|
+
if self._observer is None:
|
|
255
|
+
self._observer = Observer()
|
|
256
|
+
self._observer.start()
|
|
257
|
+
|
|
258
|
+
reg = self
|
|
259
|
+
|
|
260
|
+
# watchdog event_type -> the Workbench change vocabulary
|
|
261
|
+
# (notify's classification). moved: from-half reports deleted,
|
|
262
|
+
# to-half reports created — the same semantics as the local
|
|
263
|
+
# watcher's rename handling (Stage 11).
|
|
264
|
+
def _change_type(event: Any) -> str:
|
|
265
|
+
et = getattr(event, "event_type", "")
|
|
266
|
+
return {
|
|
267
|
+
"created": "created",
|
|
268
|
+
"deleted": "deleted",
|
|
269
|
+
"modified": "modified",
|
|
270
|
+
"moved": "created",
|
|
271
|
+
"closed": "modified",
|
|
272
|
+
}.get(et, "modified")
|
|
273
|
+
|
|
274
|
+
class Handler(FileSystemEventHandler):
|
|
275
|
+
def on_any_event(self, event: Any) -> None:
|
|
276
|
+
path = getattr(event, "dest_path", None) or event.src_path
|
|
277
|
+
if not path:
|
|
278
|
+
return
|
|
279
|
+
name = os.path.basename(str(path))
|
|
280
|
+
if _ignored(name):
|
|
281
|
+
return
|
|
282
|
+
kind = "dir" if event.is_directory else "file"
|
|
283
|
+
reg._record(root, str(path), kind, _change_type(event))
|
|
284
|
+
|
|
285
|
+
watch = self._observer.schedule(Handler(), root, recursive=True)
|
|
286
|
+
self._roots[root] = watch
|
|
287
|
+
|
|
288
|
+
def unwatch(self, root: str) -> None:
|
|
289
|
+
root = posixpath.normpath(root)
|
|
290
|
+
with self._lock:
|
|
291
|
+
watch = self._roots.pop(root, None)
|
|
292
|
+
if watch is not None and self._observer is not None:
|
|
293
|
+
self._observer.unschedule(watch)
|
|
294
|
+
if not self._roots and self._observer is not None:
|
|
295
|
+
self._observer.stop()
|
|
296
|
+
self._observer = None
|
|
297
|
+
|
|
298
|
+
def _record(self, root: str, abs_path: str, kind: str, change: str) -> None:
|
|
299
|
+
with self._lock:
|
|
300
|
+
# last-write-wins on the change type; created is sticky so a
|
|
301
|
+
# create+write burst surfaces as "created" (batcher parity)
|
|
302
|
+
prev = self._pending.get(root, {}).get(abs_path)
|
|
303
|
+
if prev == ("created", kind):
|
|
304
|
+
change = "created"
|
|
305
|
+
self._pending.setdefault(root, {})[abs_path] = (change, kind)
|
|
306
|
+
if self._flush_timer is None:
|
|
307
|
+
self._flush_timer = threading.Timer(0.1, self._flush)
|
|
308
|
+
self._flush_timer.daemon = True
|
|
309
|
+
self._flush_timer.start()
|
|
310
|
+
|
|
311
|
+
def _flush(self) -> None:
|
|
312
|
+
with self._lock:
|
|
313
|
+
self._flush_timer = None
|
|
314
|
+
batches = self._pending
|
|
315
|
+
self._pending = {}
|
|
316
|
+
for root, paths in batches.items():
|
|
317
|
+
self._emit({
|
|
318
|
+
"type": "event",
|
|
319
|
+
"event": "fs.change",
|
|
320
|
+
"data": {
|
|
321
|
+
"root": root,
|
|
322
|
+
"paths": [
|
|
323
|
+
{"path": p, "change": c, "kind": k}
|
|
324
|
+
for p, (c, k) in sorted(paths.items())
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
def shutdown(self) -> None:
|
|
330
|
+
# Grace for in-flight inotify callbacks (events land a few ms after
|
|
331
|
+
# the op result frame), then flush any debounced batch BEFORE
|
|
332
|
+
# stopping — the serve loop's EOF teardown would drop the last burst
|
|
333
|
+
# otherwise.
|
|
334
|
+
time.sleep(0.15)
|
|
335
|
+
if self._flush_timer is not None:
|
|
336
|
+
self._flush_timer.cancel()
|
|
337
|
+
self._flush()
|
|
338
|
+
with self._lock:
|
|
339
|
+
obs, self._observer = self._observer, None
|
|
340
|
+
self._roots.clear()
|
|
341
|
+
if obs is not None:
|
|
342
|
+
obs.stop()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def op_fs_watch(_ns, payload: Dict[str, Any], runtime) -> Tuple[Any, int, list]:
|
|
346
|
+
root = str(payload.get("root") or "")
|
|
347
|
+
if not root.startswith("/"):
|
|
348
|
+
raise CliError(message="fs.watch requires an absolute root",
|
|
349
|
+
exit_code=2, error_code="AISC_ERR_FS_PATH")
|
|
350
|
+
runtime.fs_watches.watch(root)
|
|
351
|
+
return {"root": posixpath.normpath(root)}, 0, []
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def op_fs_unwatch(_ns, payload: Dict[str, Any], runtime) -> Tuple[Any, int, list]:
|
|
355
|
+
root = posixpath.normpath(str(payload.get("root") or "/"))
|
|
356
|
+
runtime.fs_watches.unwatch(root)
|
|
357
|
+
return {}, 0, []
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
OPS: Dict[str, Callable[..., Tuple[Any, int, list]]] = {
|
|
361
|
+
"fs.list": op_fs_list,
|
|
362
|
+
"fs.read": op_fs_read,
|
|
363
|
+
"fs.write": op_fs_write,
|
|
364
|
+
"fs.mkdir": op_fs_mkdir,
|
|
365
|
+
"fs.rename": op_fs_rename,
|
|
366
|
+
"fs.delete": op_fs_delete,
|
|
367
|
+
"fs.watch": op_fs_watch,
|
|
368
|
+
"fs.unwatch": op_fs_unwatch,
|
|
369
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Session commands implementation.
|
|
2
|
+
|
|
3
|
+
Implements ``aisc session`` subcommands: open, list, terminate.
|
|
4
|
+
Per docs/gui-planning/05-cli-gui-contract.md §6.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from aisc.adapters.docker_ import DockerExecutor, RealDockerExecutor
|
|
13
|
+
from aisc.application.session import (
|
|
14
|
+
list_sessions,
|
|
15
|
+
open_session,
|
|
16
|
+
terminate_session,
|
|
17
|
+
validate_agent,
|
|
18
|
+
validate_session_id,
|
|
19
|
+
)
|
|
20
|
+
from aisc.domain.models import (
|
|
21
|
+
CliError,
|
|
22
|
+
RuntimeErrorCode,
|
|
23
|
+
RuntimeExitCode,
|
|
24
|
+
SessionAgent,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_workspace_and_registry(workspace: Optional[str]) -> Tuple[str, Path]:
|
|
29
|
+
"""Return (canonical_workspace_str, registry_root_path).
|
|
30
|
+
|
|
31
|
+
Stage 7: the registry root is the data-root state dir
|
|
32
|
+
(``workspaces/<hash>/runtime``), never a workspace path (DATA-01);
|
|
33
|
+
legacy ``<workspace>/.aisc`` state is adopted on first use.
|
|
34
|
+
"""
|
|
35
|
+
from aisc.application.data_root import workspace_state_dir
|
|
36
|
+
|
|
37
|
+
ws_path = Path(workspace).resolve() if workspace else Path.cwd()
|
|
38
|
+
return str(ws_path), workspace_state_dir(ws_path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# session open
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def cmd_session_open(
|
|
46
|
+
runtime_id: str,
|
|
47
|
+
session_id: str,
|
|
48
|
+
agent: str,
|
|
49
|
+
workspace: Optional[str] = None,
|
|
50
|
+
executor: Optional[DockerExecutor] = None,
|
|
51
|
+
resume_conversation_id: Optional[str] = None,
|
|
52
|
+
) -> Tuple[Dict[str, Any], int]:
|
|
53
|
+
"""Execute ``aisc session open`` per contract §6.1.
|
|
54
|
+
|
|
55
|
+
This is a text-only interactive command. It validates inputs, resolves
|
|
56
|
+
the running container, then runs ``docker exec -it`` with inherited
|
|
57
|
+
stdio. The agent's exit code becomes the process exit code.
|
|
58
|
+
|
|
59
|
+
v2.1.8 T4: ``resume_conversation_id`` forwards to the wrapper
|
|
60
|
+
(``--resume-id``) for provider-native conversation resume.
|
|
61
|
+
|
|
62
|
+
Returns (data_dict, exit_code). The caller is expected to ``sys.exit()``
|
|
63
|
+
with the exit code; the interactive streams are already inherited.
|
|
64
|
+
"""
|
|
65
|
+
exec_ = executor or RealDockerExecutor()
|
|
66
|
+
_ws, reg_root = _resolve_workspace_and_registry(workspace)
|
|
67
|
+
|
|
68
|
+
proc = open_session(
|
|
69
|
+
runtime_id=runtime_id,
|
|
70
|
+
session_id=session_id,
|
|
71
|
+
agent=agent,
|
|
72
|
+
executor=exec_,
|
|
73
|
+
registry_root=reg_root,
|
|
74
|
+
resume_conversation_id=resume_conversation_id,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
exit_code = proc.exit_code if proc.exit_code >= 0 else 1
|
|
78
|
+
|
|
79
|
+
data: Dict[str, Any] = {
|
|
80
|
+
"runtime_id": runtime_id,
|
|
81
|
+
"session_id": session_id,
|
|
82
|
+
"agent": agent,
|
|
83
|
+
"exit_code": exit_code,
|
|
84
|
+
}
|
|
85
|
+
if proc.command_not_found:
|
|
86
|
+
# 2.1.9 hotfix (#61): open_interactive packs the real failure text
|
|
87
|
+
# (daemon unreachable / exec create / exec start / transport) into
|
|
88
|
+
# stderr — surface it so print_session_text shows the true cause in
|
|
89
|
+
# the terminal instead of a misleading "docker command not found".
|
|
90
|
+
data["error"] = (proc.stderr or "").strip()[:200] or "docker command not found"
|
|
91
|
+
elif proc.timed_out:
|
|
92
|
+
data["error"] = "session timed out"
|
|
93
|
+
|
|
94
|
+
return data, exit_code
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# session list
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def cmd_session_list(
|
|
102
|
+
runtime_id: str,
|
|
103
|
+
workspace: Optional[str] = None,
|
|
104
|
+
executor: Optional[DockerExecutor] = None,
|
|
105
|
+
) -> Dict[str, Any]:
|
|
106
|
+
"""Execute ``aisc session list`` per contract §6.2."""
|
|
107
|
+
exec_ = executor or RealDockerExecutor()
|
|
108
|
+
_ws, reg_root = _resolve_workspace_and_registry(workspace)
|
|
109
|
+
|
|
110
|
+
sessions = list_sessions(
|
|
111
|
+
runtime_id=runtime_id,
|
|
112
|
+
executor=exec_,
|
|
113
|
+
registry_root=reg_root,
|
|
114
|
+
)
|
|
115
|
+
return {"sessions": sessions, "count": len(sessions)}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# session terminate
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def cmd_session_terminate(
|
|
123
|
+
runtime_id: str,
|
|
124
|
+
session_id: str,
|
|
125
|
+
workspace: Optional[str] = None,
|
|
126
|
+
executor: Optional[DockerExecutor] = None,
|
|
127
|
+
grace_seconds: float = 5.0,
|
|
128
|
+
) -> Dict[str, Any]:
|
|
129
|
+
"""Execute ``aisc session terminate`` per contract §6.2."""
|
|
130
|
+
exec_ = executor or RealDockerExecutor()
|
|
131
|
+
_ws, reg_root = _resolve_workspace_and_registry(workspace)
|
|
132
|
+
|
|
133
|
+
result = terminate_session(
|
|
134
|
+
runtime_id=runtime_id,
|
|
135
|
+
session_id=session_id,
|
|
136
|
+
executor=exec_,
|
|
137
|
+
registry_root=reg_root,
|
|
138
|
+
grace_seconds=grace_seconds,
|
|
139
|
+
)
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Text output
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def print_session_text(subcommand: str, data: Any, errors: list) -> None:
|
|
148
|
+
"""Minimal human-readable output for ``aisc session`` text mode."""
|
|
149
|
+
if errors:
|
|
150
|
+
for e in errors:
|
|
151
|
+
print(f"Error: {e.get('message', '')}")
|
|
152
|
+
return
|
|
153
|
+
if not isinstance(data, dict):
|
|
154
|
+
return
|
|
155
|
+
if subcommand == "open":
|
|
156
|
+
print(f"session {data.get('session_id', '')} agent={data.get('agent', '')} "
|
|
157
|
+
f"exit_code={data.get('exit_code', '')}")
|
|
158
|
+
elif subcommand == "list":
|
|
159
|
+
sessions = data.get("sessions", [])
|
|
160
|
+
if not sessions:
|
|
161
|
+
print("(no sessions)")
|
|
162
|
+
for s in sessions:
|
|
163
|
+
print(f"{s.get('session_id', '')} agent={s.get('agent', '')} "
|
|
164
|
+
f"state={s.get('state', '')} pid={s.get('pid', '')}")
|
|
165
|
+
elif subcommand == "terminate":
|
|
166
|
+
print(f"session {data.get('session_id', '')} state={data.get('state', '')} "
|
|
167
|
+
f"exit_code={data.get('exit_code', '')}")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""``aisc update`` command surface (0.1.0 A4, D-7).
|
|
2
|
+
|
|
3
|
+
``--check`` prints the plan; the bare form performs the frozen-exe update.
|
|
4
|
+
Envelope-compatible JSON in ``--format json``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
import aisc
|
|
13
|
+
from aisc.application.bundle_fetch import BundleFetchError, BundleFetcher
|
|
14
|
+
from aisc.application.update import (
|
|
15
|
+
UPDATE_ERROR_STATE,
|
|
16
|
+
UPDATE_ERROR_USAGE,
|
|
17
|
+
UpdateError,
|
|
18
|
+
build_check_report,
|
|
19
|
+
detect_install_form,
|
|
20
|
+
perform_update,
|
|
21
|
+
)
|
|
22
|
+
from aisc.domain.models import CliError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _root_for_tools() -> Path:
|
|
26
|
+
from aisc.application.resources import locate_aisc_root
|
|
27
|
+
|
|
28
|
+
root = locate_aisc_root()
|
|
29
|
+
if root is None:
|
|
30
|
+
raise CliError(
|
|
31
|
+
message="tool pins need an AISC root (repo or bundle) to layer "
|
|
32
|
+
"against — run `aisc bundle fetch` first or use --aisc-root",
|
|
33
|
+
exit_code=1, error_code="AISC_ERR_GENERAL")
|
|
34
|
+
return root
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _data_root() -> Path:
|
|
38
|
+
from aisc.application.data_root import shared_root
|
|
39
|
+
|
|
40
|
+
return shared_root()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _to_cli_error(exc: Exception) -> CliError:
|
|
44
|
+
if isinstance(exc, (UpdateError, BundleFetchError)):
|
|
45
|
+
return CliError(message=exc.message, exit_code=1, error_code="AISC_ERR_GENERAL")
|
|
46
|
+
raise exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_update_check(version: Optional[str] = None) -> Dict[str, Any]:
|
|
50
|
+
from aisc.application.tool_versions import tools_check_section
|
|
51
|
+
|
|
52
|
+
form = detect_install_form()
|
|
53
|
+
try:
|
|
54
|
+
fetcher = BundleFetcher()
|
|
55
|
+
report = build_check_report(
|
|
56
|
+
cli_version=aisc.__version__,
|
|
57
|
+
form=form,
|
|
58
|
+
fetcher=fetcher,
|
|
59
|
+
requested_version=version,
|
|
60
|
+
)
|
|
61
|
+
except (UpdateError, BundleFetchError) as exc:
|
|
62
|
+
raise _to_cli_error(exc) from exc
|
|
63
|
+
# A5: the tools block is network-tolerant (latest=null on registry
|
|
64
|
+
# failure) — a check must never fail because the network did.
|
|
65
|
+
report["tools"] = tools_check_section(_root_for_tools())
|
|
66
|
+
return report
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_update(
|
|
70
|
+
*,
|
|
71
|
+
version: Optional[str] = None,
|
|
72
|
+
from_file: Optional[str] = None,
|
|
73
|
+
sha256: Optional[str] = None,
|
|
74
|
+
rebuild: bool = False,
|
|
75
|
+
pin_tool: Optional[list] = None,
|
|
76
|
+
) -> Dict[str, Any]:
|
|
77
|
+
if pin_tool:
|
|
78
|
+
from aisc.application.tool_versions import write_user_pins
|
|
79
|
+
|
|
80
|
+
pins: Dict[str, str] = {}
|
|
81
|
+
for spec in pin_tool:
|
|
82
|
+
if "=" not in spec:
|
|
83
|
+
raise CliError(
|
|
84
|
+
message=f"--pin-tool expects KEY=VALUE, got {spec!r} "
|
|
85
|
+
f"(pinnable: CLAUDE_CODE_VERSION, CODEX_VERSION, CC_SWITCH_VERSION)",
|
|
86
|
+
exit_code=2, error_code="AISC_ERR_USAGE")
|
|
87
|
+
k, v = spec.split("=", 1)
|
|
88
|
+
pins[k.strip()] = v.strip()
|
|
89
|
+
_root_for_tools() # fail fast when there is no root to layer against
|
|
90
|
+
try:
|
|
91
|
+
path = write_user_pins(_data_root(), pins)
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise CliError(message=str(exc), exit_code=2,
|
|
94
|
+
error_code="AISC_ERR_USAGE") from exc
|
|
95
|
+
return {
|
|
96
|
+
"status": "pinned",
|
|
97
|
+
"path": str(path),
|
|
98
|
+
"pins": pins,
|
|
99
|
+
"note": "pins apply at the next image build (aisc build / docker-rebuild); "
|
|
100
|
+
"they survive CLI updates, installer upgrades and bundle fetches",
|
|
101
|
+
}
|
|
102
|
+
form = detect_install_form()
|
|
103
|
+
try:
|
|
104
|
+
return perform_update(
|
|
105
|
+
cli_version=aisc.__version__,
|
|
106
|
+
form=form,
|
|
107
|
+
fetcher=BundleFetcher(),
|
|
108
|
+
version=version,
|
|
109
|
+
from_file=Path(from_file) if from_file else None,
|
|
110
|
+
sha256=sha256,
|
|
111
|
+
rebuild=rebuild,
|
|
112
|
+
)
|
|
113
|
+
except (UpdateError, BundleFetchError) as exc:
|
|
114
|
+
raise _to_cli_error(exc) from exc
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def print_update_text(sub: str, data: Any) -> None:
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
return
|
|
120
|
+
if sub == "check":
|
|
121
|
+
print(f"form : {data.get('form')}")
|
|
122
|
+
print(f"current : {data.get('cli_version')}")
|
|
123
|
+
print(f"latest : {data.get('latest_final') or '(none)'}")
|
|
124
|
+
print(f"action : {data.get('action')}")
|
|
125
|
+
if data.get("update_available") is not None:
|
|
126
|
+
print(f"available: {data.get('update_available')}")
|
|
127
|
+
for t in data.get("tools") or []:
|
|
128
|
+
latest = t.get("latest") or "unknown"
|
|
129
|
+
mark = "*" if t.get("source") == "user" else " "
|
|
130
|
+
print(f" tool {mark} {t.get('tool')}: current {t.get('current')} "
|
|
131
|
+
f"({t.get('source')}) | latest {latest}")
|
|
132
|
+
else:
|
|
133
|
+
if data.get("status") == "pinned":
|
|
134
|
+
print(f"status : pinned -> {data.get('path')}")
|
|
135
|
+
for k, v in (data.get("pins") or {}).items():
|
|
136
|
+
print(f" {k}={v}")
|
|
137
|
+
print(f"note : {data.get('note')}")
|
|
138
|
+
return
|
|
139
|
+
print(f"status: {data.get('status')}")
|
|
140
|
+
if data.get("to_version"):
|
|
141
|
+
print(f"version: {data.get('from_version')} -> {data.get('to_version')}")
|
|
142
|
+
if data.get("preserved_old_exe"):
|
|
143
|
+
print(f"old exe kept at: {data['preserved_old_exe']} (rollback copy)")
|
|
144
|
+
if data.get("rebuild_exit") is not None:
|
|
145
|
+
print(f"rebuild exit: {data['rebuild_exit']}")
|