msdev 0.9.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.
- msdev/__init__.py +3 -0
- msdev/cli.py +1460 -0
- msdev/core/__init__.py +1 -0
- msdev/core/config.py +193 -0
- msdev/core/guides.py +100 -0
- msdev/core/inventory.py +1390 -0
- msdev/core/limits.py +44 -0
- msdev/core/resources.py +300 -0
- msdev/core/services/__init__.py +111 -0
- msdev/core/services/context.py +61 -0
- msdev/core/services/environment.py +84 -0
- msdev/core/services/execution.py +136 -0
- msdev/core/services/model.py +1085 -0
- msdev/core/services/node.py +210 -0
- msdev/core/services/npu.py +116 -0
- msdev/core/services/workspace.py +567 -0
- msdev/core/transport.py +1225 -0
- msdev/core/workspace/__init__.py +36 -0
- msdev/core/workspace/access.py +1216 -0
- msdev/core/workspace/client.py +274 -0
- msdev/core/workspace/paths.py +45 -0
- msdev/core/workspace/registry.py +151 -0
- msdev/daemon.py +1322 -0
- msdev/session/__init__.py +13 -0
- msdev/session/export.py +190 -0
- msdev/session/log.py +269 -0
- msdev-0.9.0.dist-info/METADATA +295 -0
- msdev-0.9.0.dist-info/RECORD +31 -0
- msdev-0.9.0.dist-info/WHEEL +5 -0
- msdev-0.9.0.dist-info/entry_points.txt +3 -0
- msdev-0.9.0.dist-info/top_level.txt +1 -0
msdev/cli.py
ADDED
|
@@ -0,0 +1,1460 @@
|
|
|
1
|
+
"""msdev client CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .core.guides import (
|
|
17
|
+
EnvironmentGuideStore,
|
|
18
|
+
MAX_GUIDE_BYTES,
|
|
19
|
+
NodeGuideStore,
|
|
20
|
+
)
|
|
21
|
+
from .core.limits import DEFAULT_EXEC_TIMEOUT_SECONDS, MAX_INVENTORY_CONTENT_BYTES
|
|
22
|
+
from .core.services import (
|
|
23
|
+
EnvironmentAddRequest,
|
|
24
|
+
EnvironmentService,
|
|
25
|
+
ExecRequest,
|
|
26
|
+
ExecutionService,
|
|
27
|
+
ModelAuditRequest,
|
|
28
|
+
ModelDiscoverRequest,
|
|
29
|
+
ModelImportRequest,
|
|
30
|
+
ModelListRequest,
|
|
31
|
+
ModelRefRequest,
|
|
32
|
+
ModelRegisterRequest,
|
|
33
|
+
ModelRebindRequest,
|
|
34
|
+
ModelRefreshRequest,
|
|
35
|
+
ModelReplicaAddRequest,
|
|
36
|
+
ModelReplicaRemoveRequest,
|
|
37
|
+
ModelService,
|
|
38
|
+
ModelUnregisterRequest,
|
|
39
|
+
ModelUpdateRequest,
|
|
40
|
+
ModelValidateRequest,
|
|
41
|
+
ModelVerifyRequest,
|
|
42
|
+
NpuService,
|
|
43
|
+
ServiceContext,
|
|
44
|
+
NodeAddRequest,
|
|
45
|
+
NodeService,
|
|
46
|
+
WorkspaceAddRequest,
|
|
47
|
+
WorkspaceService,
|
|
48
|
+
)
|
|
49
|
+
from .core.resources import EnvironmentStore, NodeStore
|
|
50
|
+
from .core.transport import (
|
|
51
|
+
RpcError,
|
|
52
|
+
SshRpcTransport,
|
|
53
|
+
UnixRpcTransport,
|
|
54
|
+
bootstrap_node,
|
|
55
|
+
)
|
|
56
|
+
from .core.workspace import WorkspaceStore
|
|
57
|
+
from .session.log import CliOperationLogger, begin_session, monotonic_ms
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _StrictArgumentParser(argparse.ArgumentParser):
|
|
61
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
62
|
+
kwargs.setdefault("allow_abbrev", False)
|
|
63
|
+
super().__init__(*args, **kwargs)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _print_json(value: Any) -> None:
|
|
67
|
+
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _human_size(value: int | None) -> str:
|
|
71
|
+
size = float(value or 0)
|
|
72
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
73
|
+
if size < 1024 or unit == "TiB":
|
|
74
|
+
return f"{size:.1f}{unit}"
|
|
75
|
+
size /= 1024
|
|
76
|
+
return f"{size:.1f}TiB"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _service_context(
|
|
80
|
+
node_store: NodeStore,
|
|
81
|
+
environment_store: EnvironmentStore,
|
|
82
|
+
workspace_store: WorkspaceStore | None = None,
|
|
83
|
+
) -> ServiceContext:
|
|
84
|
+
values: dict[str, Any] = {
|
|
85
|
+
"node_store": node_store,
|
|
86
|
+
"environment_store": environment_store,
|
|
87
|
+
"create_transport": lambda node, environment=None: (
|
|
88
|
+
UnixRpcTransport()
|
|
89
|
+
if node is None
|
|
90
|
+
else SshRpcTransport(node, environment)
|
|
91
|
+
),
|
|
92
|
+
"bootstrap": bootstrap_node,
|
|
93
|
+
}
|
|
94
|
+
if workspace_store is not None:
|
|
95
|
+
values["workspace_store"] = workspace_store
|
|
96
|
+
return ServiceContext(
|
|
97
|
+
**values,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_root_maps(values: list[str]) -> tuple[tuple[str, str], ...]:
|
|
102
|
+
result: list[tuple[str, str]] = []
|
|
103
|
+
for value in values:
|
|
104
|
+
old, separator, new = value.partition("=")
|
|
105
|
+
if not separator or not old or not new:
|
|
106
|
+
raise ValueError(f"invalid root mapping {value!r}; expected OLD=NEW")
|
|
107
|
+
result.append((old, new))
|
|
108
|
+
return tuple(result)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _read_inventory(path: Path) -> str:
|
|
112
|
+
with path.open("rb") as handle:
|
|
113
|
+
raw = handle.read(MAX_INVENTORY_CONTENT_BYTES + 1)
|
|
114
|
+
if len(raw) > MAX_INVENTORY_CONTENT_BYTES:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"inventory export exceeds {MAX_INVENTORY_CONTENT_BYTES} bytes: "
|
|
117
|
+
f"{path}"
|
|
118
|
+
)
|
|
119
|
+
content = raw.decode("utf-8")
|
|
120
|
+
if not content.splitlines():
|
|
121
|
+
raise ValueError(f"empty inventory export: {path}")
|
|
122
|
+
return content
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _write_inventory(path: Path, content: str) -> None:
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
path.write_text(content, encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _command_guide(
|
|
131
|
+
args: argparse.Namespace,
|
|
132
|
+
guide_store: NodeGuideStore | EnvironmentGuideStore,
|
|
133
|
+
resource_type: str,
|
|
134
|
+
) -> int:
|
|
135
|
+
if args.write is not None:
|
|
136
|
+
if args.write == "-":
|
|
137
|
+
raw = sys.stdin.buffer.read(MAX_GUIDE_BYTES + 1)
|
|
138
|
+
else:
|
|
139
|
+
with Path(args.write).open("rb") as handle:
|
|
140
|
+
raw = handle.read(MAX_GUIDE_BYTES + 1)
|
|
141
|
+
guide_store.write_bytes(args.name, raw)
|
|
142
|
+
print(f"updated guide for {resource_type} {args.name}")
|
|
143
|
+
return 0
|
|
144
|
+
if args.delete:
|
|
145
|
+
deleted = guide_store.delete(args.name)
|
|
146
|
+
action = "deleted guide for" if deleted else "no guide configured for"
|
|
147
|
+
print(f"{action} {resource_type} {args.name}")
|
|
148
|
+
return 0
|
|
149
|
+
content = guide_store.read(args.name)
|
|
150
|
+
if content is None:
|
|
151
|
+
print(f"no guide configured for {resource_type} {args.name}")
|
|
152
|
+
else:
|
|
153
|
+
print(content, end="" if content.endswith("\n") else "\n")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def command_node(
|
|
158
|
+
args: argparse.Namespace,
|
|
159
|
+
node_store: NodeStore,
|
|
160
|
+
environment_store: EnvironmentStore,
|
|
161
|
+
) -> int:
|
|
162
|
+
service = NodeService(_service_context(node_store, environment_store))
|
|
163
|
+
action = args.node_command
|
|
164
|
+
if action == "guide":
|
|
165
|
+
if args.name != "local":
|
|
166
|
+
node_store.get(args.name)
|
|
167
|
+
return _command_guide(args, NodeGuideStore(), "node")
|
|
168
|
+
if action == "env":
|
|
169
|
+
node = (
|
|
170
|
+
service.update_variables(
|
|
171
|
+
args.name,
|
|
172
|
+
set_values=tuple(args.set_values),
|
|
173
|
+
unset_keys=tuple(args.unset_keys),
|
|
174
|
+
)
|
|
175
|
+
if args.set_values or args.unset_keys
|
|
176
|
+
else node_store.get(args.name)
|
|
177
|
+
)
|
|
178
|
+
for assignment in node.variables:
|
|
179
|
+
print(assignment)
|
|
180
|
+
return 0
|
|
181
|
+
if action == "add":
|
|
182
|
+
node = service.add(
|
|
183
|
+
NodeAddRequest(
|
|
184
|
+
name=args.name,
|
|
185
|
+
ssh_host=args.ssh_host,
|
|
186
|
+
remote_bin=args.remote_bin,
|
|
187
|
+
auto_bootstrap=not args.no_auto_bootstrap,
|
|
188
|
+
force=args.force,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
print(
|
|
192
|
+
f"added node {node.name} (SSH Host {node.ssh_host}); "
|
|
193
|
+
f"created host environment {node.name}"
|
|
194
|
+
)
|
|
195
|
+
return 0
|
|
196
|
+
if action == "list":
|
|
197
|
+
print("local")
|
|
198
|
+
for node in service.list().nodes:
|
|
199
|
+
print(
|
|
200
|
+
f"{node.name}\tssh={node.ssh_host}\t"
|
|
201
|
+
f"auto-bootstrap={'on' if node.auto_bootstrap else 'off'}\t"
|
|
202
|
+
f"bin={node.remote_bin}"
|
|
203
|
+
)
|
|
204
|
+
return 0
|
|
205
|
+
if action == "remove":
|
|
206
|
+
removed = service.remove(args.name, cascade=args.cascade)
|
|
207
|
+
print(f"removed node {args.name}")
|
|
208
|
+
if removed:
|
|
209
|
+
print("removed environments: " + ", ".join(removed))
|
|
210
|
+
return 0
|
|
211
|
+
if action == "status":
|
|
212
|
+
print(f"node: {args.name}")
|
|
213
|
+
_print_json(service.status(args.name))
|
|
214
|
+
return 0
|
|
215
|
+
if action == "bootstrap":
|
|
216
|
+
result = service.bootstrap(args.name, args.package_path)
|
|
217
|
+
print(f"bootstrapped node {args.name}")
|
|
218
|
+
_print_json(result)
|
|
219
|
+
return 0
|
|
220
|
+
if action in {"connect", "disconnect"}:
|
|
221
|
+
result = (
|
|
222
|
+
service.connect(args.name, all_nodes=args.all)
|
|
223
|
+
if action == "connect"
|
|
224
|
+
else service.disconnect(args.name, all_nodes=args.all)
|
|
225
|
+
)
|
|
226
|
+
for item in result.items:
|
|
227
|
+
if item.error is not None:
|
|
228
|
+
print(f"{item.node}: error: {item.error}", file=sys.stderr)
|
|
229
|
+
elif action == "connect":
|
|
230
|
+
print(
|
|
231
|
+
f"{item.node}: {item.state} to {item.ssh_host} "
|
|
232
|
+
f"(persist={item.persist})"
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
print(f"{item.node}: {item.state}")
|
|
236
|
+
return result.exit_code
|
|
237
|
+
raise ValueError(f"unsupported node command: {action}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def command_environment(
|
|
241
|
+
args: argparse.Namespace,
|
|
242
|
+
node_store: NodeStore,
|
|
243
|
+
environment_store: EnvironmentStore,
|
|
244
|
+
) -> int:
|
|
245
|
+
service = EnvironmentService(_service_context(node_store, environment_store))
|
|
246
|
+
action = args.environment_command
|
|
247
|
+
if action == "guide":
|
|
248
|
+
environment_store.get(args.name)
|
|
249
|
+
return _command_guide(args, EnvironmentGuideStore(), "environment")
|
|
250
|
+
if action == "shell":
|
|
251
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
252
|
+
raise ValueError("environment shell requires an interactive terminal")
|
|
253
|
+
return service.shell(args.name, cwd=args.cwd)
|
|
254
|
+
if action == "add":
|
|
255
|
+
environment = service.add(
|
|
256
|
+
EnvironmentAddRequest(
|
|
257
|
+
name=args.name,
|
|
258
|
+
node=args.node,
|
|
259
|
+
docker_container=args.docker_container,
|
|
260
|
+
layers=tuple(args.layer),
|
|
261
|
+
force=args.force,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
print(f"added environment {environment.name} on node {environment.node}")
|
|
265
|
+
return 0
|
|
266
|
+
if action == "list":
|
|
267
|
+
print("local")
|
|
268
|
+
for environment in service.list().environments:
|
|
269
|
+
runtime = (
|
|
270
|
+
f"docker:{environment.container}"
|
|
271
|
+
if environment.runtime == "docker"
|
|
272
|
+
else "host"
|
|
273
|
+
)
|
|
274
|
+
layers = ">".join(environment.layers) if environment.layers else "-"
|
|
275
|
+
print(
|
|
276
|
+
f"{environment.name}\tnode={environment.node}\t"
|
|
277
|
+
f"runtime={runtime}\tlayers={layers}"
|
|
278
|
+
)
|
|
279
|
+
return 0
|
|
280
|
+
if action == "inspect":
|
|
281
|
+
_print_json(asdict(service.inspect(args.name)))
|
|
282
|
+
return 0
|
|
283
|
+
if action == "remove":
|
|
284
|
+
service.remove(args.name)
|
|
285
|
+
print(f"removed environment {args.name}")
|
|
286
|
+
return 0
|
|
287
|
+
raise ValueError(f"unsupported environment command: {action}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def command_npu(
|
|
291
|
+
args: argparse.Namespace,
|
|
292
|
+
node_store: NodeStore,
|
|
293
|
+
environment_store: EnvironmentStore,
|
|
294
|
+
) -> int:
|
|
295
|
+
service = NpuService(_service_context(node_store, environment_store))
|
|
296
|
+
if args.all:
|
|
297
|
+
service_result = service.list(all_nodes=True)
|
|
298
|
+
results = {
|
|
299
|
+
item.node: {
|
|
300
|
+
"ssh_host": item.ssh_host,
|
|
301
|
+
**(
|
|
302
|
+
{"error": item.error}
|
|
303
|
+
if item.error is not None
|
|
304
|
+
else {"result": item.result}
|
|
305
|
+
),
|
|
306
|
+
}
|
|
307
|
+
for item in service_result.items
|
|
308
|
+
}
|
|
309
|
+
if args.json:
|
|
310
|
+
_print_json(results)
|
|
311
|
+
else:
|
|
312
|
+
for item in service_result.items:
|
|
313
|
+
value = results[item.node]
|
|
314
|
+
print(f"===== {item.node} ({item.ssh_host}) =====")
|
|
315
|
+
if "error" in value:
|
|
316
|
+
print(f"error: {value['error']}", file=sys.stderr)
|
|
317
|
+
continue
|
|
318
|
+
result = value["result"]
|
|
319
|
+
if result.get("reason"):
|
|
320
|
+
print(result["reason"], file=sys.stderr)
|
|
321
|
+
print(result.get("output", ""), end="")
|
|
322
|
+
return service_result.exit_code
|
|
323
|
+
|
|
324
|
+
service_result = service.list(node=args.node)
|
|
325
|
+
item = service_result.items[0]
|
|
326
|
+
result = item.result or {}
|
|
327
|
+
if args.json:
|
|
328
|
+
_print_json({"node": item.node, **result})
|
|
329
|
+
else:
|
|
330
|
+
print(f"node: {item.node}")
|
|
331
|
+
if result.get("reason"):
|
|
332
|
+
print(result["reason"], file=sys.stderr)
|
|
333
|
+
print(result.get("output", ""), end="")
|
|
334
|
+
return service_result.exit_code
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def command_model(
|
|
338
|
+
args: argparse.Namespace,
|
|
339
|
+
node_store: NodeStore,
|
|
340
|
+
environment_store: EnvironmentStore,
|
|
341
|
+
) -> int:
|
|
342
|
+
service = ModelService(_service_context(node_store, environment_store))
|
|
343
|
+
if args.model_command == "register":
|
|
344
|
+
result = service.register(
|
|
345
|
+
ModelRegisterRequest(
|
|
346
|
+
node=args.node,
|
|
347
|
+
ref=args.ref,
|
|
348
|
+
path=args.path,
|
|
349
|
+
scope="user",
|
|
350
|
+
format_name=args.format,
|
|
351
|
+
full_checksum=args.full_checksum,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
print(f"registered on {result.node}: {args.ref}")
|
|
355
|
+
_print_json(result.value)
|
|
356
|
+
return result.exit_code
|
|
357
|
+
if args.model_command == "discover":
|
|
358
|
+
result = service.discover(
|
|
359
|
+
ModelDiscoverRequest(
|
|
360
|
+
node=args.node,
|
|
361
|
+
root=args.root,
|
|
362
|
+
max_depth=args.max_depth,
|
|
363
|
+
register=args.register,
|
|
364
|
+
namespace=args.namespace,
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
if args.json:
|
|
368
|
+
_print_json(result.value)
|
|
369
|
+
return result.exit_code
|
|
370
|
+
models = result.value["models"]
|
|
371
|
+
action = "registered" if args.register else "found"
|
|
372
|
+
print(
|
|
373
|
+
f"{action} {len(models)} model(s) under {result.value['root']} "
|
|
374
|
+
f"on {result.node}"
|
|
375
|
+
)
|
|
376
|
+
if models:
|
|
377
|
+
print("REF\tFORMAT\tSIZE\tFILES\tPATH")
|
|
378
|
+
for item in models:
|
|
379
|
+
print(
|
|
380
|
+
f"{item['ref']}\t{item['format']}\t"
|
|
381
|
+
f"{_human_size(item['size_bytes'])}\t"
|
|
382
|
+
f"{item['file_count']}\t{item['path']}"
|
|
383
|
+
)
|
|
384
|
+
return result.exit_code
|
|
385
|
+
if args.model_command == "list":
|
|
386
|
+
result = service.list(
|
|
387
|
+
ModelListRequest(
|
|
388
|
+
node=args.node,
|
|
389
|
+
all_nodes=args.all,
|
|
390
|
+
format_name=args.format_filter,
|
|
391
|
+
model_type=args.model_type,
|
|
392
|
+
state=args.state,
|
|
393
|
+
tag=args.tag_filter,
|
|
394
|
+
name=args.name_filter,
|
|
395
|
+
include_deleted=args.include_deleted,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
if args.all:
|
|
399
|
+
nodes = [
|
|
400
|
+
{
|
|
401
|
+
"node": item.node,
|
|
402
|
+
"ssh_host": item.ssh_host,
|
|
403
|
+
"models": list(item.models),
|
|
404
|
+
}
|
|
405
|
+
for item in result.nodes
|
|
406
|
+
]
|
|
407
|
+
errors = [
|
|
408
|
+
{
|
|
409
|
+
"node": item.node,
|
|
410
|
+
"ssh_host": item.ssh_host,
|
|
411
|
+
"error": item.error,
|
|
412
|
+
}
|
|
413
|
+
for item in result.errors
|
|
414
|
+
]
|
|
415
|
+
if args.json:
|
|
416
|
+
_print_json({"nodes": nodes, "errors": errors})
|
|
417
|
+
else:
|
|
418
|
+
rows = [
|
|
419
|
+
(node.node, model)
|
|
420
|
+
for node in result.nodes
|
|
421
|
+
for model in node.models
|
|
422
|
+
]
|
|
423
|
+
if rows:
|
|
424
|
+
print("NODE\tREF\tSTATE\tVERIFY\tSIZE\tTAGS\tPATH")
|
|
425
|
+
for node_name, item in rows:
|
|
426
|
+
print(
|
|
427
|
+
f"{node_name}\t{item['ref']}\t"
|
|
428
|
+
f"{item.get('state') or '-'}\t"
|
|
429
|
+
f"{item.get('verification_level') or '-'}\t"
|
|
430
|
+
f"{_human_size(item.get('size_bytes'))}\t"
|
|
431
|
+
f"{','.join(item.get('tags') or []) or '-'}\t"
|
|
432
|
+
f"{item.get('path') or '-'}"
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
print("no models registered across SSH nodes")
|
|
436
|
+
for error in result.errors:
|
|
437
|
+
print(
|
|
438
|
+
f"{error.node} ({error.ssh_host}): "
|
|
439
|
+
f"error: {error.error}",
|
|
440
|
+
file=sys.stderr,
|
|
441
|
+
)
|
|
442
|
+
return result.exit_code
|
|
443
|
+
|
|
444
|
+
node = result.nodes[0]
|
|
445
|
+
models = node.models
|
|
446
|
+
if args.json:
|
|
447
|
+
_print_json(list(models))
|
|
448
|
+
return result.exit_code
|
|
449
|
+
if not models:
|
|
450
|
+
print(f"no models registered on {node.node}")
|
|
451
|
+
return result.exit_code
|
|
452
|
+
print("REF\tSTATE\tVERIFY\tSIZE\tTAGS\tPATH")
|
|
453
|
+
for item in models:
|
|
454
|
+
print(
|
|
455
|
+
f"{item['ref']}\t{item.get('state') or '-'}\t"
|
|
456
|
+
f"{item.get('verification_level') or '-'}\t"
|
|
457
|
+
f"{_human_size(item.get('size_bytes'))}\t"
|
|
458
|
+
f"{','.join(item.get('tags') or []) or '-'}\t"
|
|
459
|
+
f"{item.get('path') or '-'}"
|
|
460
|
+
)
|
|
461
|
+
return result.exit_code
|
|
462
|
+
if args.model_command == "inspect":
|
|
463
|
+
result = service.inspect(ModelRefRequest(args.node, args.ref))
|
|
464
|
+
_print_json(result.value)
|
|
465
|
+
return result.exit_code
|
|
466
|
+
if args.model_command == "update":
|
|
467
|
+
result = service.update(
|
|
468
|
+
ModelUpdateRequest(
|
|
469
|
+
node=args.node,
|
|
470
|
+
ref=args.ref,
|
|
471
|
+
add_aliases=tuple(args.add_alias),
|
|
472
|
+
remove_aliases=tuple(args.remove_alias),
|
|
473
|
+
add_tags=tuple(args.add_tag),
|
|
474
|
+
remove_tags=tuple(args.remove_tag),
|
|
475
|
+
description=args.description,
|
|
476
|
+
clear_description=args.clear_description,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
_print_json(result.value)
|
|
480
|
+
return result.exit_code
|
|
481
|
+
if args.model_command == "delete":
|
|
482
|
+
result = service.delete(ModelRefRequest(args.node, args.ref))
|
|
483
|
+
_print_json(result.value)
|
|
484
|
+
return result.exit_code
|
|
485
|
+
if args.model_command == "restore":
|
|
486
|
+
result = service.restore(ModelRefRequest(args.node, args.ref))
|
|
487
|
+
_print_json(result.value)
|
|
488
|
+
return result.exit_code
|
|
489
|
+
if args.model_command == "refresh":
|
|
490
|
+
result = service.refresh(
|
|
491
|
+
ModelRefreshRequest(
|
|
492
|
+
node=args.node,
|
|
493
|
+
ref=args.ref,
|
|
494
|
+
path=args.path,
|
|
495
|
+
full_checksum=args.full_checksum,
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
_print_json(result.value)
|
|
499
|
+
return result.exit_code
|
|
500
|
+
if args.model_command == "validate":
|
|
501
|
+
result = service.validate(
|
|
502
|
+
ModelValidateRequest(
|
|
503
|
+
node=args.node,
|
|
504
|
+
ref=args.ref,
|
|
505
|
+
path=args.path,
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
_print_json(result.value)
|
|
509
|
+
return result.exit_code
|
|
510
|
+
if args.model_command == "audit":
|
|
511
|
+
result = service.audit(
|
|
512
|
+
ModelAuditRequest(
|
|
513
|
+
node=args.node,
|
|
514
|
+
ref=args.ref,
|
|
515
|
+
limit=args.limit,
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
_print_json(result.value)
|
|
519
|
+
return result.exit_code
|
|
520
|
+
if args.model_command == "replica":
|
|
521
|
+
if args.replica_command == "list":
|
|
522
|
+
result = service.replica_list(
|
|
523
|
+
ModelRefRequest(args.node, args.ref)
|
|
524
|
+
)
|
|
525
|
+
_print_json(result.value)
|
|
526
|
+
return result.exit_code
|
|
527
|
+
if args.replica_command == "add":
|
|
528
|
+
result = service.replica_add(
|
|
529
|
+
ModelReplicaAddRequest(
|
|
530
|
+
node=args.node,
|
|
531
|
+
ref=args.ref,
|
|
532
|
+
path=args.path,
|
|
533
|
+
full_checksum=args.full_checksum,
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
_print_json(result.value)
|
|
537
|
+
return result.exit_code
|
|
538
|
+
if args.replica_command == "remove":
|
|
539
|
+
result = service.replica_remove(
|
|
540
|
+
ModelReplicaRemoveRequest(
|
|
541
|
+
node=args.node,
|
|
542
|
+
ref=args.ref,
|
|
543
|
+
path=args.path,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
_print_json(result.value)
|
|
547
|
+
return result.exit_code
|
|
548
|
+
if args.model_command == "verify":
|
|
549
|
+
result = service.verify(
|
|
550
|
+
ModelVerifyRequest(
|
|
551
|
+
node=args.node,
|
|
552
|
+
ref=args.ref,
|
|
553
|
+
full_checksum=args.full_checksum,
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
_print_json(result.value)
|
|
557
|
+
return result.exit_code
|
|
558
|
+
if args.model_command == "unregister":
|
|
559
|
+
result = service.unregister(
|
|
560
|
+
ModelUnregisterRequest(
|
|
561
|
+
node=args.node,
|
|
562
|
+
ref=args.ref,
|
|
563
|
+
path=args.path,
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
_print_json(result.value)
|
|
567
|
+
return result.exit_code
|
|
568
|
+
if args.model_command == "export":
|
|
569
|
+
result = service.export(args.node)
|
|
570
|
+
_write_inventory(args.output, result.jsonl)
|
|
571
|
+
print(
|
|
572
|
+
f"exported {len(result.payload.get('artifacts', []))} artifacts "
|
|
573
|
+
f"from {result.node} to {args.output}"
|
|
574
|
+
)
|
|
575
|
+
return result.exit_code
|
|
576
|
+
if args.model_command == "import":
|
|
577
|
+
result = service.import_records(
|
|
578
|
+
ModelImportRequest(
|
|
579
|
+
node=args.node,
|
|
580
|
+
jsonl=_read_inventory(args.input),
|
|
581
|
+
root_maps=_parse_root_maps(args.map_root),
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
_print_json(result.value)
|
|
585
|
+
return result.exit_code
|
|
586
|
+
if args.model_command == "rebind":
|
|
587
|
+
result = service.rebind(
|
|
588
|
+
ModelRebindRequest(
|
|
589
|
+
node=args.node,
|
|
590
|
+
ref=args.ref,
|
|
591
|
+
path=args.path,
|
|
592
|
+
scope="user",
|
|
593
|
+
full_checksum=args.full_checksum,
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
_print_json(result.value)
|
|
597
|
+
return result.exit_code
|
|
598
|
+
raise ValueError(f"unsupported model command: {args.model_command}")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def command_exec(
|
|
602
|
+
args: argparse.Namespace,
|
|
603
|
+
node_store: NodeStore,
|
|
604
|
+
environment_store: EnvironmentStore,
|
|
605
|
+
) -> int:
|
|
606
|
+
command = list(args.argv)
|
|
607
|
+
if command and command[0] == "--":
|
|
608
|
+
command = command[1:]
|
|
609
|
+
if not command:
|
|
610
|
+
raise ValueError("missing command after `msdev exec --`")
|
|
611
|
+
env: dict[str, str] = {}
|
|
612
|
+
for value in args.env_var:
|
|
613
|
+
key, separator, item = value.partition("=")
|
|
614
|
+
if not separator or not key:
|
|
615
|
+
raise ValueError(f"invalid environment assignment: {value!r}")
|
|
616
|
+
env[key] = item
|
|
617
|
+
request = ExecRequest(
|
|
618
|
+
environment=args.environment,
|
|
619
|
+
argv=tuple(command),
|
|
620
|
+
cwd=args.cwd,
|
|
621
|
+
env=env,
|
|
622
|
+
timeout_seconds=args.timeout_seconds,
|
|
623
|
+
)
|
|
624
|
+
service = ExecutionService(_service_context(node_store, environment_store))
|
|
625
|
+
if args.result_json:
|
|
626
|
+
result = service.run(request)
|
|
627
|
+
_print_json(result.to_dict())
|
|
628
|
+
else:
|
|
629
|
+
def on_output(stream_name: str, chunk: bytes) -> None:
|
|
630
|
+
destination = (
|
|
631
|
+
sys.stdout if stream_name == "stdout" else sys.stderr
|
|
632
|
+
)
|
|
633
|
+
binary = getattr(destination, "buffer", None)
|
|
634
|
+
if binary is not None:
|
|
635
|
+
binary.write(chunk)
|
|
636
|
+
binary.flush()
|
|
637
|
+
else:
|
|
638
|
+
destination.write(chunk.decode("utf-8", errors="replace"))
|
|
639
|
+
destination.flush()
|
|
640
|
+
|
|
641
|
+
result = service.stream(request, on_output)
|
|
642
|
+
return result.returncode
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _print_workspace_git_result(
|
|
646
|
+
result: Any,
|
|
647
|
+
*,
|
|
648
|
+
result_json: bool = False,
|
|
649
|
+
) -> int:
|
|
650
|
+
if result.stdout:
|
|
651
|
+
print(result.stdout, end="")
|
|
652
|
+
if result.stderr:
|
|
653
|
+
print(result.stderr, end="", file=sys.stderr)
|
|
654
|
+
if result.stdout_truncated or result.stderr_truncated:
|
|
655
|
+
print(
|
|
656
|
+
"warning: remote command output was truncated at the daemon limit",
|
|
657
|
+
file=sys.stderr,
|
|
658
|
+
)
|
|
659
|
+
if result_json:
|
|
660
|
+
_print_json(result.to_dict())
|
|
661
|
+
return result.returncode
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _workspace_write_content(args: argparse.Namespace) -> bytes:
|
|
665
|
+
if args.content is not None:
|
|
666
|
+
return args.content.encode("utf-8")
|
|
667
|
+
if args.file is not None:
|
|
668
|
+
return args.file.read_bytes()
|
|
669
|
+
return sys.stdin.buffer.read()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def command_workspace(
|
|
673
|
+
args: argparse.Namespace,
|
|
674
|
+
node_store: NodeStore,
|
|
675
|
+
environment_store: EnvironmentStore,
|
|
676
|
+
workspace_store: WorkspaceStore,
|
|
677
|
+
) -> int:
|
|
678
|
+
action = args.workspace_command
|
|
679
|
+
service = WorkspaceService(
|
|
680
|
+
_service_context(node_store, environment_store, workspace_store)
|
|
681
|
+
)
|
|
682
|
+
if action == "add":
|
|
683
|
+
workspace = service.add(
|
|
684
|
+
WorkspaceAddRequest(
|
|
685
|
+
name=args.name,
|
|
686
|
+
environment=args.environment,
|
|
687
|
+
root=args.root,
|
|
688
|
+
execution_root=args.execution_root,
|
|
689
|
+
force=args.force,
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
print(
|
|
693
|
+
f"added workspace {workspace.name} "
|
|
694
|
+
f"(environment={workspace.environment}, root={workspace.root})"
|
|
695
|
+
)
|
|
696
|
+
return 0
|
|
697
|
+
if action == "list" and args.name is None:
|
|
698
|
+
workspaces = service.list().workspaces
|
|
699
|
+
if args.json:
|
|
700
|
+
_print_json([workspace.__dict__ for workspace in workspaces])
|
|
701
|
+
else:
|
|
702
|
+
for workspace in workspaces:
|
|
703
|
+
print(
|
|
704
|
+
f"{workspace.name}\tenvironment={workspace.environment}\t"
|
|
705
|
+
f"root={workspace.root}\t"
|
|
706
|
+
f"execution-root={workspace.execution_root or workspace.root}"
|
|
707
|
+
)
|
|
708
|
+
return 0
|
|
709
|
+
if action == "inspect":
|
|
710
|
+
_print_json(service.inspect(args.name).to_dict())
|
|
711
|
+
return 0
|
|
712
|
+
if action == "remove":
|
|
713
|
+
result = service.remove(args.name)
|
|
714
|
+
print(f"removed workspace {result.name}")
|
|
715
|
+
return 0
|
|
716
|
+
|
|
717
|
+
if action == "stat":
|
|
718
|
+
_print_json(service.stat(args.name, args.path).raw)
|
|
719
|
+
return 0
|
|
720
|
+
if action == "read":
|
|
721
|
+
result = service.read(
|
|
722
|
+
args.name,
|
|
723
|
+
args.path,
|
|
724
|
+
max_bytes=args.max_bytes,
|
|
725
|
+
)
|
|
726
|
+
if args.json:
|
|
727
|
+
_print_json(result.raw)
|
|
728
|
+
elif args.output:
|
|
729
|
+
args.output.write_bytes(result.content)
|
|
730
|
+
else:
|
|
731
|
+
sys.stdout.buffer.write(result.content)
|
|
732
|
+
sys.stdout.buffer.flush()
|
|
733
|
+
return 0
|
|
734
|
+
if action in {"list", "list-files"}:
|
|
735
|
+
result = service.list_directory(args.name, args.path).raw
|
|
736
|
+
if args.json:
|
|
737
|
+
_print_json(result)
|
|
738
|
+
else:
|
|
739
|
+
for item in result["entries"]:
|
|
740
|
+
print(f"{item['type']}\t{item['size'] if item['size'] is not None else '-'}\t{item['path']}")
|
|
741
|
+
return 0
|
|
742
|
+
if action == "glob":
|
|
743
|
+
result = service.glob(
|
|
744
|
+
args.name,
|
|
745
|
+
args.pattern,
|
|
746
|
+
max_results=args.max_results,
|
|
747
|
+
)
|
|
748
|
+
if args.json:
|
|
749
|
+
_print_json(result.raw)
|
|
750
|
+
else:
|
|
751
|
+
for path in result.matches:
|
|
752
|
+
print(path)
|
|
753
|
+
return 0
|
|
754
|
+
if action == "search":
|
|
755
|
+
result = service.search(
|
|
756
|
+
args.name,
|
|
757
|
+
args.pattern,
|
|
758
|
+
paths=args.paths,
|
|
759
|
+
glob=args.glob_filter,
|
|
760
|
+
max_results=args.max_results,
|
|
761
|
+
)
|
|
762
|
+
if args.json:
|
|
763
|
+
_print_json(result.raw)
|
|
764
|
+
else:
|
|
765
|
+
for item in result.matches:
|
|
766
|
+
print(f"{item['path']}:{item['line']}:{item['text']}")
|
|
767
|
+
return 0
|
|
768
|
+
if action in {"write", "apply-patch"}:
|
|
769
|
+
content = _workspace_write_content(args)
|
|
770
|
+
if action == "apply-patch":
|
|
771
|
+
result = service.apply_patch(
|
|
772
|
+
args.name,
|
|
773
|
+
args.path,
|
|
774
|
+
content,
|
|
775
|
+
expected_sha256=args.expected_sha256,
|
|
776
|
+
)
|
|
777
|
+
else:
|
|
778
|
+
result = service.write(args.name, args.path, content)
|
|
779
|
+
_print_json(result.raw)
|
|
780
|
+
return 0
|
|
781
|
+
if action == "delete":
|
|
782
|
+
_print_json(service.delete(args.name, args.path).raw)
|
|
783
|
+
return 0
|
|
784
|
+
if action == "git-status":
|
|
785
|
+
return _print_workspace_git_result(
|
|
786
|
+
service.git_status(args.name),
|
|
787
|
+
result_json=args.result_json,
|
|
788
|
+
)
|
|
789
|
+
if action == "git-diff":
|
|
790
|
+
return _print_workspace_git_result(
|
|
791
|
+
service.git_diff(args.name, args.argv),
|
|
792
|
+
result_json=args.result_json,
|
|
793
|
+
)
|
|
794
|
+
raise ValueError(f"unsupported workspace command: {action}")
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def command_session(args: argparse.Namespace) -> int:
|
|
798
|
+
from .session.export import (
|
|
799
|
+
iter_events,
|
|
800
|
+
list_sessions,
|
|
801
|
+
reject_output_alias,
|
|
802
|
+
render_markdown,
|
|
803
|
+
resolve_session_sources,
|
|
804
|
+
session_log_location,
|
|
805
|
+
write_markdown_atomic,
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
if args.session_command == "begin":
|
|
809
|
+
session = begin_session(args.name)
|
|
810
|
+
value = {
|
|
811
|
+
"session_id": session.session_id,
|
|
812
|
+
"path": str(session.path),
|
|
813
|
+
}
|
|
814
|
+
if args.json:
|
|
815
|
+
_print_json(value)
|
|
816
|
+
else:
|
|
817
|
+
print(session.session_id)
|
|
818
|
+
print(session.path)
|
|
819
|
+
return 0
|
|
820
|
+
if args.session_command == "list":
|
|
821
|
+
sessions = list_sessions(session_log_location())
|
|
822
|
+
if not sessions:
|
|
823
|
+
print("no CLI session logs found")
|
|
824
|
+
return 0
|
|
825
|
+
for session in sessions:
|
|
826
|
+
print(f"{session.name}\t{session.size} bytes\tmtime_ns={session.mtime_ns}")
|
|
827
|
+
return 0
|
|
828
|
+
if args.session_command == "export":
|
|
829
|
+
sources = resolve_session_sources(
|
|
830
|
+
latest=args.latest,
|
|
831
|
+
inputs=args.input,
|
|
832
|
+
session_id=args.export_session_id,
|
|
833
|
+
)
|
|
834
|
+
chunks = render_markdown(iter_events(sources), args.detail)
|
|
835
|
+
if args.output is None:
|
|
836
|
+
for chunk in chunks:
|
|
837
|
+
sys.stdout.write(chunk)
|
|
838
|
+
sys.stdout.flush()
|
|
839
|
+
else:
|
|
840
|
+
reject_output_alias(args.output, sources)
|
|
841
|
+
write_markdown_atomic(args.output, chunks)
|
|
842
|
+
print(f"exported session runbook to {args.output}")
|
|
843
|
+
return 0
|
|
844
|
+
raise ValueError(f"unsupported session command: {args.session_command}")
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
848
|
+
parser = _StrictArgumentParser(
|
|
849
|
+
prog="msdev",
|
|
850
|
+
description="Manage msdev nodes and execution environments",
|
|
851
|
+
)
|
|
852
|
+
parser.add_argument(
|
|
853
|
+
"--config",
|
|
854
|
+
type=Path,
|
|
855
|
+
default=None,
|
|
856
|
+
help="override local node/environment registry path",
|
|
857
|
+
)
|
|
858
|
+
parser.add_argument(
|
|
859
|
+
"--workspace-config",
|
|
860
|
+
type=Path,
|
|
861
|
+
default=None,
|
|
862
|
+
help="override local workspace registry path",
|
|
863
|
+
)
|
|
864
|
+
parser.add_argument(
|
|
865
|
+
"--session-id",
|
|
866
|
+
default=os.environ.get("MSDEV_SESSION_ID"),
|
|
867
|
+
help="explicit CLI session used for operation logs",
|
|
868
|
+
)
|
|
869
|
+
parser.add_argument(
|
|
870
|
+
"--intent-kind",
|
|
871
|
+
choices=("execution", "verification", "diagnostic", "exploration"),
|
|
872
|
+
default=os.environ.get("MSDEV_INTENT_KIND"),
|
|
873
|
+
)
|
|
874
|
+
parser.add_argument(
|
|
875
|
+
"--intent-summary",
|
|
876
|
+
default=os.environ.get("MSDEV_INTENT_SUMMARY"),
|
|
877
|
+
)
|
|
878
|
+
parser.add_argument(
|
|
879
|
+
"--intent-phase",
|
|
880
|
+
default=os.environ.get("MSDEV_INTENT_PHASE"),
|
|
881
|
+
)
|
|
882
|
+
parser.add_argument(
|
|
883
|
+
"--intent-step-id",
|
|
884
|
+
default=os.environ.get("MSDEV_INTENT_STEP_ID"),
|
|
885
|
+
)
|
|
886
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
887
|
+
|
|
888
|
+
node = sub.add_parser("node", help="manage SSH connection nodes")
|
|
889
|
+
node_sub = node.add_subparsers(dest="node_command", required=True)
|
|
890
|
+
node_add = node_sub.add_parser("add")
|
|
891
|
+
node_add.add_argument("name")
|
|
892
|
+
node_add.add_argument("--ssh-host", help="OpenSSH Host alias; defaults to name")
|
|
893
|
+
node_add.add_argument("--remote-bin", default="~/.local/bin/msdevd")
|
|
894
|
+
node_add.add_argument(
|
|
895
|
+
"--force",
|
|
896
|
+
action="store_true",
|
|
897
|
+
help="replace an existing node and its same-named host environment",
|
|
898
|
+
)
|
|
899
|
+
node_add.add_argument(
|
|
900
|
+
"--no-auto-bootstrap",
|
|
901
|
+
action="store_true",
|
|
902
|
+
help="do not install/start msdevd automatically on first use",
|
|
903
|
+
)
|
|
904
|
+
node_sub.add_parser("list")
|
|
905
|
+
node_env = node_sub.add_parser(
|
|
906
|
+
"env",
|
|
907
|
+
help="read or update persistent host environment variables",
|
|
908
|
+
)
|
|
909
|
+
node_env.add_argument("name")
|
|
910
|
+
node_env.add_argument(
|
|
911
|
+
"--set",
|
|
912
|
+
dest="set_values",
|
|
913
|
+
action="append",
|
|
914
|
+
default=[],
|
|
915
|
+
metavar="KEY=VALUE",
|
|
916
|
+
)
|
|
917
|
+
node_env.add_argument(
|
|
918
|
+
"--unset",
|
|
919
|
+
dest="unset_keys",
|
|
920
|
+
action="append",
|
|
921
|
+
default=[],
|
|
922
|
+
metavar="KEY",
|
|
923
|
+
)
|
|
924
|
+
node_guide = node_sub.add_parser(
|
|
925
|
+
"guide",
|
|
926
|
+
help="read or maintain the local Markdown guide for a node",
|
|
927
|
+
)
|
|
928
|
+
node_guide.add_argument("name")
|
|
929
|
+
node_guide_action = node_guide.add_mutually_exclusive_group()
|
|
930
|
+
node_guide_action.add_argument(
|
|
931
|
+
"--write",
|
|
932
|
+
metavar="FILE",
|
|
933
|
+
help="replace the guide from FILE, or - for stdin",
|
|
934
|
+
)
|
|
935
|
+
node_guide_action.add_argument(
|
|
936
|
+
"--delete",
|
|
937
|
+
action="store_true",
|
|
938
|
+
help="delete the local guide without removing the node",
|
|
939
|
+
)
|
|
940
|
+
node_remove = node_sub.add_parser("remove")
|
|
941
|
+
node_remove.add_argument("name")
|
|
942
|
+
node_remove.add_argument(
|
|
943
|
+
"--cascade",
|
|
944
|
+
action="store_true",
|
|
945
|
+
help="also remove every environment on this node",
|
|
946
|
+
)
|
|
947
|
+
node_status = node_sub.add_parser("status")
|
|
948
|
+
node_status.add_argument("name")
|
|
949
|
+
node_bootstrap = node_sub.add_parser("bootstrap")
|
|
950
|
+
node_bootstrap.add_argument("name")
|
|
951
|
+
node_bootstrap.add_argument("--package-path", type=Path)
|
|
952
|
+
node_connect = node_sub.add_parser(
|
|
953
|
+
"connect",
|
|
954
|
+
help="open a persistent SSH master and prompt once for authentication",
|
|
955
|
+
)
|
|
956
|
+
node_connect.add_argument("name", nargs="?")
|
|
957
|
+
node_connect.add_argument("--all", action="store_true")
|
|
958
|
+
node_disconnect = node_sub.add_parser(
|
|
959
|
+
"disconnect",
|
|
960
|
+
help="close a persistent SSH master",
|
|
961
|
+
)
|
|
962
|
+
node_disconnect.add_argument("name", nargs="?")
|
|
963
|
+
node_disconnect.add_argument("--all", action="store_true")
|
|
964
|
+
|
|
965
|
+
environment = sub.add_parser(
|
|
966
|
+
"env",
|
|
967
|
+
help="manage host, Docker, conda, venv, and uv execution environments",
|
|
968
|
+
)
|
|
969
|
+
environment_sub = environment.add_subparsers(
|
|
970
|
+
dest="environment_command",
|
|
971
|
+
required=True,
|
|
972
|
+
)
|
|
973
|
+
environment_add = environment_sub.add_parser("add")
|
|
974
|
+
environment_add.add_argument("name")
|
|
975
|
+
environment_add.add_argument("--node", required=True)
|
|
976
|
+
environment_add.add_argument("--docker-container")
|
|
977
|
+
environment_add.add_argument(
|
|
978
|
+
"--layer",
|
|
979
|
+
action="append",
|
|
980
|
+
default=[],
|
|
981
|
+
metavar="TYPE:VALUE",
|
|
982
|
+
help=(
|
|
983
|
+
"append an environment layer; repeat for nesting "
|
|
984
|
+
"(conda:<name-or-prefix>, venv:<path>, uv:<project-path>)"
|
|
985
|
+
),
|
|
986
|
+
)
|
|
987
|
+
environment_add.add_argument("--force", action="store_true")
|
|
988
|
+
environment_sub.add_parser("list")
|
|
989
|
+
environment_inspect = environment_sub.add_parser("inspect")
|
|
990
|
+
environment_inspect.add_argument("name")
|
|
991
|
+
environment_guide = environment_sub.add_parser("guide")
|
|
992
|
+
environment_guide.add_argument("name")
|
|
993
|
+
environment_guide_action = environment_guide.add_mutually_exclusive_group()
|
|
994
|
+
environment_guide_action.add_argument("--write", metavar="FILE")
|
|
995
|
+
environment_guide_action.add_argument("--delete", action="store_true")
|
|
996
|
+
environment_shell = environment_sub.add_parser("shell")
|
|
997
|
+
environment_shell.add_argument("name")
|
|
998
|
+
environment_shell.add_argument("--cwd")
|
|
999
|
+
environment_remove = environment_sub.add_parser("remove")
|
|
1000
|
+
environment_remove.add_argument("name")
|
|
1001
|
+
|
|
1002
|
+
workspace = sub.add_parser(
|
|
1003
|
+
"workspace",
|
|
1004
|
+
help="manage and access explicit remote workspaces",
|
|
1005
|
+
)
|
|
1006
|
+
workspace_sub = workspace.add_subparsers(
|
|
1007
|
+
dest="workspace_command",
|
|
1008
|
+
required=True,
|
|
1009
|
+
)
|
|
1010
|
+
workspace_add = workspace_sub.add_parser("add")
|
|
1011
|
+
workspace_add.add_argument("name")
|
|
1012
|
+
workspace_add.add_argument("--env", dest="environment", required=True)
|
|
1013
|
+
workspace_add.add_argument("--root", required=True)
|
|
1014
|
+
workspace_add.add_argument(
|
|
1015
|
+
"--execution-root",
|
|
1016
|
+
help="command cwd in the environment runtime",
|
|
1017
|
+
)
|
|
1018
|
+
workspace_add.add_argument("--force", action="store_true")
|
|
1019
|
+
|
|
1020
|
+
workspace_list = workspace_sub.add_parser(
|
|
1021
|
+
"list",
|
|
1022
|
+
help="list registered workspaces, or list a named workspace directory",
|
|
1023
|
+
)
|
|
1024
|
+
workspace_list.add_argument("name", nargs="?")
|
|
1025
|
+
workspace_list.add_argument("path", nargs="?", default=".")
|
|
1026
|
+
workspace_list.add_argument("--json", action="store_true")
|
|
1027
|
+
|
|
1028
|
+
workspace_inspect = workspace_sub.add_parser("inspect")
|
|
1029
|
+
workspace_inspect.add_argument("name")
|
|
1030
|
+
workspace_remove = workspace_sub.add_parser("remove")
|
|
1031
|
+
workspace_remove.add_argument("name")
|
|
1032
|
+
|
|
1033
|
+
workspace_stat = workspace_sub.add_parser("stat")
|
|
1034
|
+
workspace_stat.add_argument("name")
|
|
1035
|
+
workspace_stat.add_argument("path", nargs="?", default=".")
|
|
1036
|
+
|
|
1037
|
+
workspace_read = workspace_sub.add_parser("read")
|
|
1038
|
+
workspace_read.add_argument("name")
|
|
1039
|
+
workspace_read.add_argument("path")
|
|
1040
|
+
workspace_read.add_argument("--max-bytes", type=int, default=1024 * 1024)
|
|
1041
|
+
workspace_read.add_argument("--output", type=Path)
|
|
1042
|
+
workspace_read.add_argument("--json", action="store_true")
|
|
1043
|
+
|
|
1044
|
+
workspace_glob = workspace_sub.add_parser("glob")
|
|
1045
|
+
workspace_glob.add_argument("name")
|
|
1046
|
+
workspace_glob.add_argument("pattern")
|
|
1047
|
+
workspace_glob.add_argument("--max-results", type=int, default=1000)
|
|
1048
|
+
workspace_glob.add_argument("--json", action="store_true")
|
|
1049
|
+
|
|
1050
|
+
workspace_search = workspace_sub.add_parser("search")
|
|
1051
|
+
workspace_search.add_argument("name")
|
|
1052
|
+
workspace_search.add_argument("pattern")
|
|
1053
|
+
workspace_search.add_argument("paths", nargs="*", default=["."])
|
|
1054
|
+
workspace_search.add_argument("--glob", dest="glob_filter")
|
|
1055
|
+
workspace_search.add_argument("--max-results", type=int, default=1000)
|
|
1056
|
+
workspace_search.add_argument("--json", action="store_true")
|
|
1057
|
+
|
|
1058
|
+
for command_name, help_text in (
|
|
1059
|
+
("write", "atomically replace a workspace file"),
|
|
1060
|
+
(
|
|
1061
|
+
"apply-patch",
|
|
1062
|
+
"compare-and-swap a workspace file using its expected SHA256",
|
|
1063
|
+
),
|
|
1064
|
+
):
|
|
1065
|
+
write_parser = workspace_sub.add_parser(command_name, help=help_text)
|
|
1066
|
+
write_parser.add_argument("name")
|
|
1067
|
+
write_parser.add_argument("path")
|
|
1068
|
+
content_source = write_parser.add_mutually_exclusive_group()
|
|
1069
|
+
content_source.add_argument("--content")
|
|
1070
|
+
content_source.add_argument("--file", type=Path)
|
|
1071
|
+
if command_name == "apply-patch":
|
|
1072
|
+
write_parser.add_argument("--expected-sha256", required=True)
|
|
1073
|
+
|
|
1074
|
+
workspace_delete = workspace_sub.add_parser("delete")
|
|
1075
|
+
workspace_delete.add_argument("name")
|
|
1076
|
+
workspace_delete.add_argument("path")
|
|
1077
|
+
|
|
1078
|
+
workspace_git_status = workspace_sub.add_parser("git-status")
|
|
1079
|
+
workspace_git_status.add_argument("name")
|
|
1080
|
+
workspace_git_status.add_argument("--result-json", action="store_true")
|
|
1081
|
+
workspace_git_diff = workspace_sub.add_parser("git-diff")
|
|
1082
|
+
workspace_git_diff.add_argument("name")
|
|
1083
|
+
workspace_git_diff.add_argument("--result-json", action="store_true")
|
|
1084
|
+
workspace_git_diff.add_argument("argv", nargs=argparse.REMAINDER)
|
|
1085
|
+
|
|
1086
|
+
npu = sub.add_parser("npu", help="inspect NPU state")
|
|
1087
|
+
npu_sub = npu.add_subparsers(dest="npu_command", required=True)
|
|
1088
|
+
npu_list = npu_sub.add_parser("list")
|
|
1089
|
+
npu_scope = npu_list.add_mutually_exclusive_group(required=True)
|
|
1090
|
+
npu_scope.add_argument("--node")
|
|
1091
|
+
npu_scope.add_argument(
|
|
1092
|
+
"--all",
|
|
1093
|
+
action="store_true",
|
|
1094
|
+
help="query every registered SSH node",
|
|
1095
|
+
)
|
|
1096
|
+
npu_list.add_argument("--json", action="store_true")
|
|
1097
|
+
|
|
1098
|
+
model = sub.add_parser("model", help="manage model inventory")
|
|
1099
|
+
model_sub = model.add_subparsers(dest="model_command", required=True)
|
|
1100
|
+
register = model_sub.add_parser("register")
|
|
1101
|
+
register.add_argument("--node", required=True)
|
|
1102
|
+
register.add_argument("--ref", required=True)
|
|
1103
|
+
register.add_argument("--path", required=True)
|
|
1104
|
+
register.add_argument("--format")
|
|
1105
|
+
register.add_argument("--full-checksum", action="store_true")
|
|
1106
|
+
|
|
1107
|
+
discover = model_sub.add_parser(
|
|
1108
|
+
"discover",
|
|
1109
|
+
help="find model snapshots under a remote directory",
|
|
1110
|
+
)
|
|
1111
|
+
discover.add_argument("--node", required=True)
|
|
1112
|
+
discover.add_argument("--root", required=True)
|
|
1113
|
+
discover.add_argument("--max-depth", type=int, default=5)
|
|
1114
|
+
discover.add_argument("--namespace", default="discovered")
|
|
1115
|
+
discover.add_argument(
|
|
1116
|
+
"--register",
|
|
1117
|
+
action="store_true",
|
|
1118
|
+
help="register discovered snapshots after scanning",
|
|
1119
|
+
)
|
|
1120
|
+
discover.add_argument("--json", action="store_true")
|
|
1121
|
+
|
|
1122
|
+
model_list = model_sub.add_parser("list")
|
|
1123
|
+
model_list_scope = model_list.add_mutually_exclusive_group(required=True)
|
|
1124
|
+
model_list_scope.add_argument("--node")
|
|
1125
|
+
model_list_scope.add_argument(
|
|
1126
|
+
"--all",
|
|
1127
|
+
action="store_true",
|
|
1128
|
+
help="aggregate inventories from every registered SSH node",
|
|
1129
|
+
)
|
|
1130
|
+
model_list.add_argument("--format", dest="format_filter")
|
|
1131
|
+
model_list.add_argument("--model-type")
|
|
1132
|
+
model_list.add_argument(
|
|
1133
|
+
"--state",
|
|
1134
|
+
choices=["ready", "dirty", "missing", "broken"],
|
|
1135
|
+
)
|
|
1136
|
+
model_list.add_argument("--tag", dest="tag_filter")
|
|
1137
|
+
model_list.add_argument("--name", dest="name_filter")
|
|
1138
|
+
model_list.add_argument("--include-deleted", action="store_true")
|
|
1139
|
+
model_list.add_argument("--json", action="store_true")
|
|
1140
|
+
|
|
1141
|
+
inspect = model_sub.add_parser("inspect")
|
|
1142
|
+
inspect.add_argument("ref")
|
|
1143
|
+
inspect.add_argument("--node", required=True)
|
|
1144
|
+
|
|
1145
|
+
update = model_sub.add_parser("update")
|
|
1146
|
+
update.add_argument("ref")
|
|
1147
|
+
update.add_argument("--node", required=True)
|
|
1148
|
+
update.add_argument("--add-alias", action="append", default=[])
|
|
1149
|
+
update.add_argument("--remove-alias", action="append", default=[])
|
|
1150
|
+
update.add_argument("--add-tag", action="append", default=[])
|
|
1151
|
+
update.add_argument("--remove-tag", action="append", default=[])
|
|
1152
|
+
description_group = update.add_mutually_exclusive_group()
|
|
1153
|
+
description_group.add_argument("--description")
|
|
1154
|
+
description_group.add_argument("--clear-description", action="store_true")
|
|
1155
|
+
|
|
1156
|
+
delete = model_sub.add_parser("delete", help="soft-delete model metadata")
|
|
1157
|
+
delete.add_argument("ref")
|
|
1158
|
+
delete.add_argument("--node", required=True)
|
|
1159
|
+
|
|
1160
|
+
restore = model_sub.add_parser("restore")
|
|
1161
|
+
restore.add_argument("ref")
|
|
1162
|
+
restore.add_argument("--node", required=True)
|
|
1163
|
+
|
|
1164
|
+
refresh = model_sub.add_parser("refresh")
|
|
1165
|
+
refresh.add_argument("ref")
|
|
1166
|
+
refresh.add_argument("--node", required=True)
|
|
1167
|
+
refresh.add_argument("--path")
|
|
1168
|
+
refresh.add_argument("--full-checksum", action="store_true")
|
|
1169
|
+
|
|
1170
|
+
validate = model_sub.add_parser("validate")
|
|
1171
|
+
validate.add_argument("ref")
|
|
1172
|
+
validate.add_argument("--node", required=True)
|
|
1173
|
+
validate.add_argument("--path")
|
|
1174
|
+
|
|
1175
|
+
audit = model_sub.add_parser("audit")
|
|
1176
|
+
audit.add_argument("--ref")
|
|
1177
|
+
audit.add_argument("--node", required=True)
|
|
1178
|
+
audit.add_argument("--limit", type=int, default=100)
|
|
1179
|
+
|
|
1180
|
+
replica = model_sub.add_parser("replica")
|
|
1181
|
+
replica_sub = replica.add_subparsers(dest="replica_command", required=True)
|
|
1182
|
+
replica_list = replica_sub.add_parser("list")
|
|
1183
|
+
replica_list.add_argument("ref")
|
|
1184
|
+
replica_list.add_argument("--node", required=True)
|
|
1185
|
+
replica_add = replica_sub.add_parser("add")
|
|
1186
|
+
replica_add.add_argument("ref")
|
|
1187
|
+
replica_add.add_argument("--node", required=True)
|
|
1188
|
+
replica_add.add_argument("--path", required=True)
|
|
1189
|
+
replica_add.add_argument("--full-checksum", action="store_true")
|
|
1190
|
+
replica_remove = replica_sub.add_parser("remove")
|
|
1191
|
+
replica_remove.add_argument("ref")
|
|
1192
|
+
replica_remove.add_argument("--node", required=True)
|
|
1193
|
+
replica_remove.add_argument("--path", required=True)
|
|
1194
|
+
|
|
1195
|
+
verify = model_sub.add_parser("verify")
|
|
1196
|
+
verify.add_argument("ref")
|
|
1197
|
+
verify.add_argument("--node", required=True)
|
|
1198
|
+
verify.add_argument("--full-checksum", action="store_true")
|
|
1199
|
+
|
|
1200
|
+
unregister = model_sub.add_parser("unregister")
|
|
1201
|
+
unregister.add_argument("ref")
|
|
1202
|
+
unregister.add_argument("--path")
|
|
1203
|
+
unregister.add_argument("--node", required=True)
|
|
1204
|
+
|
|
1205
|
+
export = model_sub.add_parser("export")
|
|
1206
|
+
export.add_argument("--output", type=Path, required=True)
|
|
1207
|
+
export.add_argument("--node", required=True)
|
|
1208
|
+
|
|
1209
|
+
import_parser = model_sub.add_parser("import")
|
|
1210
|
+
import_parser.add_argument("input", type=Path)
|
|
1211
|
+
import_parser.add_argument("--map-root", action="append", default=[], metavar="OLD=NEW")
|
|
1212
|
+
import_parser.add_argument("--node", required=True)
|
|
1213
|
+
|
|
1214
|
+
rebind = model_sub.add_parser("rebind")
|
|
1215
|
+
rebind.add_argument("ref")
|
|
1216
|
+
rebind.add_argument("--path", required=True)
|
|
1217
|
+
rebind.add_argument("--full-checksum", action="store_true")
|
|
1218
|
+
rebind.add_argument("--node", required=True)
|
|
1219
|
+
|
|
1220
|
+
execute = sub.add_parser(
|
|
1221
|
+
"exec",
|
|
1222
|
+
help="execute a command in an explicit environment",
|
|
1223
|
+
)
|
|
1224
|
+
execute.add_argument("--env", dest="environment", required=True)
|
|
1225
|
+
execute.add_argument("--cwd")
|
|
1226
|
+
execute.add_argument(
|
|
1227
|
+
"--env-var",
|
|
1228
|
+
action="append",
|
|
1229
|
+
default=[],
|
|
1230
|
+
metavar="KEY=VALUE",
|
|
1231
|
+
)
|
|
1232
|
+
execute.add_argument(
|
|
1233
|
+
"--timeout-seconds",
|
|
1234
|
+
type=float,
|
|
1235
|
+
default=DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
1236
|
+
help="positive timeout in seconds; -1 waits indefinitely",
|
|
1237
|
+
)
|
|
1238
|
+
execute.add_argument("--result-json", action="store_true")
|
|
1239
|
+
execute.add_argument("argv", nargs=argparse.REMAINDER)
|
|
1240
|
+
|
|
1241
|
+
session = sub.add_parser(
|
|
1242
|
+
"session",
|
|
1243
|
+
help="manage and export explicit CLI operation sessions",
|
|
1244
|
+
)
|
|
1245
|
+
session_sub = session.add_subparsers(dest="session_command", required=True)
|
|
1246
|
+
session_begin = session_sub.add_parser(
|
|
1247
|
+
"begin",
|
|
1248
|
+
help="create an explicit CLI operation session",
|
|
1249
|
+
)
|
|
1250
|
+
session_begin.add_argument("--name")
|
|
1251
|
+
session_begin.add_argument("--json", action="store_true")
|
|
1252
|
+
session_sub.add_parser(
|
|
1253
|
+
"list",
|
|
1254
|
+
help="list discoverable CLI session logs",
|
|
1255
|
+
)
|
|
1256
|
+
session_export = session_sub.add_parser(
|
|
1257
|
+
"export",
|
|
1258
|
+
help="export CLI operation logs as a Markdown runbook",
|
|
1259
|
+
)
|
|
1260
|
+
session_source = session_export.add_mutually_exclusive_group(required=True)
|
|
1261
|
+
session_source.add_argument(
|
|
1262
|
+
"--input",
|
|
1263
|
+
type=Path,
|
|
1264
|
+
action="append",
|
|
1265
|
+
default=[],
|
|
1266
|
+
metavar="PATH",
|
|
1267
|
+
help="JSONL session input; repeat to merge multiple files",
|
|
1268
|
+
)
|
|
1269
|
+
session_source.add_argument(
|
|
1270
|
+
"--latest",
|
|
1271
|
+
action="store_true",
|
|
1272
|
+
help="use the latest CLI operation session log",
|
|
1273
|
+
)
|
|
1274
|
+
session_source.add_argument(
|
|
1275
|
+
"--session-id",
|
|
1276
|
+
dest="export_session_id",
|
|
1277
|
+
help="export the log for an explicit msdev session ID",
|
|
1278
|
+
)
|
|
1279
|
+
session_export.add_argument(
|
|
1280
|
+
"--output", type=Path,
|
|
1281
|
+
help="output file path (default: stdout)",
|
|
1282
|
+
)
|
|
1283
|
+
session_export.add_argument(
|
|
1284
|
+
"--detail", choices=("brief", "normal", "full"), default="normal",
|
|
1285
|
+
help="brief one-line calls, normal runbook, or full sanitized events",
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
return parser
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _operation_name(args: argparse.Namespace) -> str:
|
|
1292
|
+
parts = [args.command]
|
|
1293
|
+
for attribute in (
|
|
1294
|
+
"node_command",
|
|
1295
|
+
"environment_command",
|
|
1296
|
+
"workspace_command",
|
|
1297
|
+
"npu_command",
|
|
1298
|
+
"model_command",
|
|
1299
|
+
"replica_command",
|
|
1300
|
+
"session_command",
|
|
1301
|
+
):
|
|
1302
|
+
value = getattr(args, attribute, None)
|
|
1303
|
+
if value:
|
|
1304
|
+
parts.append(value)
|
|
1305
|
+
return ".".join(parts)
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def _replay_argv(argv: list[str]) -> list[str]:
|
|
1309
|
+
metadata = {
|
|
1310
|
+
"--session-id",
|
|
1311
|
+
"--intent-kind",
|
|
1312
|
+
"--intent-summary",
|
|
1313
|
+
"--intent-phase",
|
|
1314
|
+
"--intent-step-id",
|
|
1315
|
+
}
|
|
1316
|
+
result: list[str] = []
|
|
1317
|
+
skip_next = False
|
|
1318
|
+
for value in argv:
|
|
1319
|
+
if skip_next:
|
|
1320
|
+
skip_next = False
|
|
1321
|
+
continue
|
|
1322
|
+
option = value.partition("=")[0]
|
|
1323
|
+
if option in metadata:
|
|
1324
|
+
skip_next = "=" not in value
|
|
1325
|
+
continue
|
|
1326
|
+
result.append(value)
|
|
1327
|
+
return result
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _intent_from_args(args: argparse.Namespace) -> dict[str, str] | None:
|
|
1331
|
+
values = {
|
|
1332
|
+
"kind": args.intent_kind,
|
|
1333
|
+
"summary": args.intent_summary,
|
|
1334
|
+
"phase": args.intent_phase,
|
|
1335
|
+
"step_id": args.intent_step_id,
|
|
1336
|
+
}
|
|
1337
|
+
if not any(values.values()):
|
|
1338
|
+
return None
|
|
1339
|
+
if not values["kind"]:
|
|
1340
|
+
raise ValueError("--intent-kind is required when intent metadata is used")
|
|
1341
|
+
if not values["summary"]:
|
|
1342
|
+
raise ValueError("--intent-summary is required when intent metadata is used")
|
|
1343
|
+
return {key: value for key, value in values.items() if value}
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _watch_parent_exit(
|
|
1347
|
+
parent_pid: int,
|
|
1348
|
+
stop_event: threading.Event,
|
|
1349
|
+
*,
|
|
1350
|
+
poll_seconds: float = 0.1,
|
|
1351
|
+
) -> None:
|
|
1352
|
+
while not stop_event.wait(poll_seconds):
|
|
1353
|
+
if os.getppid() != parent_pid:
|
|
1354
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
1355
|
+
return
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def main() -> None:
|
|
1359
|
+
parser = build_parser()
|
|
1360
|
+
args = parser.parse_args()
|
|
1361
|
+
node_store = NodeStore(args.config)
|
|
1362
|
+
environment_store = EnvironmentStore(args.config)
|
|
1363
|
+
workspace_store = WorkspaceStore(args.workspace_config)
|
|
1364
|
+
started = time.monotonic()
|
|
1365
|
+
intent: dict[str, str] | None = None
|
|
1366
|
+
previous_sigterm = signal.getsignal(signal.SIGTERM)
|
|
1367
|
+
|
|
1368
|
+
def cancel_on_sigterm(_signum: int, _frame: Any) -> None:
|
|
1369
|
+
raise KeyboardInterrupt
|
|
1370
|
+
|
|
1371
|
+
signal.signal(signal.SIGTERM, cancel_on_sigterm)
|
|
1372
|
+
parent_watch_stop: threading.Event | None = None
|
|
1373
|
+
parent_watch_thread: threading.Thread | None = None
|
|
1374
|
+
if args.command == "exec":
|
|
1375
|
+
parent_watch_stop = threading.Event()
|
|
1376
|
+
parent_watch_thread = threading.Thread(
|
|
1377
|
+
target=_watch_parent_exit,
|
|
1378
|
+
args=(os.getppid(), parent_watch_stop),
|
|
1379
|
+
daemon=True,
|
|
1380
|
+
)
|
|
1381
|
+
parent_watch_thread.start()
|
|
1382
|
+
try:
|
|
1383
|
+
intent = _intent_from_args(args)
|
|
1384
|
+
if args.command == "node":
|
|
1385
|
+
code = command_node(args, node_store, environment_store)
|
|
1386
|
+
elif args.command == "env":
|
|
1387
|
+
code = command_environment(args, node_store, environment_store)
|
|
1388
|
+
elif args.command == "npu" and args.npu_command == "list":
|
|
1389
|
+
code = command_npu(args, node_store, environment_store)
|
|
1390
|
+
elif args.command == "model":
|
|
1391
|
+
code = command_model(args, node_store, environment_store)
|
|
1392
|
+
elif args.command == "exec":
|
|
1393
|
+
code = command_exec(args, node_store, environment_store)
|
|
1394
|
+
elif args.command == "workspace":
|
|
1395
|
+
code = command_workspace(
|
|
1396
|
+
args,
|
|
1397
|
+
node_store,
|
|
1398
|
+
environment_store,
|
|
1399
|
+
workspace_store,
|
|
1400
|
+
)
|
|
1401
|
+
elif args.command == "session":
|
|
1402
|
+
code = command_session(args)
|
|
1403
|
+
else:
|
|
1404
|
+
parser.error("unsupported command")
|
|
1405
|
+
return
|
|
1406
|
+
except (KeyError, ValueError, RuntimeError, RpcError, OSError) as exc:
|
|
1407
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1408
|
+
code = 2
|
|
1409
|
+
except KeyboardInterrupt:
|
|
1410
|
+
print("cancelled", file=sys.stderr)
|
|
1411
|
+
code = 130
|
|
1412
|
+
finally:
|
|
1413
|
+
if parent_watch_stop is not None:
|
|
1414
|
+
parent_watch_stop.set()
|
|
1415
|
+
if parent_watch_thread is not None:
|
|
1416
|
+
parent_watch_thread.join(timeout=0.2)
|
|
1417
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
1418
|
+
if args.session_id and args.command != "session":
|
|
1419
|
+
try:
|
|
1420
|
+
node = getattr(args, "node", None)
|
|
1421
|
+
if not isinstance(node, str):
|
|
1422
|
+
node = (
|
|
1423
|
+
getattr(args, "name", None)
|
|
1424
|
+
if args.command == "node"
|
|
1425
|
+
else None
|
|
1426
|
+
)
|
|
1427
|
+
environment = getattr(args, "environment", None)
|
|
1428
|
+
if not isinstance(environment, str):
|
|
1429
|
+
environment = (
|
|
1430
|
+
getattr(args, "name", None)
|
|
1431
|
+
if args.command == "env"
|
|
1432
|
+
else None
|
|
1433
|
+
)
|
|
1434
|
+
workspace = (
|
|
1435
|
+
getattr(args, "name", None)
|
|
1436
|
+
if args.command == "workspace"
|
|
1437
|
+
else None
|
|
1438
|
+
)
|
|
1439
|
+
CliOperationLogger(args.session_id).record(
|
|
1440
|
+
argv=_replay_argv(sys.argv[1:]),
|
|
1441
|
+
command=_operation_name(args),
|
|
1442
|
+
node=node if isinstance(node, str) else None,
|
|
1443
|
+
environment=(
|
|
1444
|
+
environment if isinstance(environment, str) else None
|
|
1445
|
+
),
|
|
1446
|
+
workspace=workspace if isinstance(workspace, str) else None,
|
|
1447
|
+
intent=intent,
|
|
1448
|
+
exit_code=code,
|
|
1449
|
+
elapsed_ms=monotonic_ms(started),
|
|
1450
|
+
)
|
|
1451
|
+
except (OSError, ValueError) as exc:
|
|
1452
|
+
print(
|
|
1453
|
+
f"warning: CLI operation log was not written: {exc}",
|
|
1454
|
+
file=sys.stderr,
|
|
1455
|
+
)
|
|
1456
|
+
raise SystemExit(code)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
if __name__ == "__main__":
|
|
1460
|
+
main()
|