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/daemon.py ADDED
@@ -0,0 +1,1322 @@
1
+ """User-level msdev daemon and Unix-socket JSON RPC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import base64
7
+ import json
8
+ import os
9
+ import platform
10
+ import select
11
+ import shlex
12
+ import shutil
13
+ import signal
14
+ import socket
15
+ import socketserver
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import time
20
+ import traceback
21
+ from pathlib import Path
22
+ from typing import Any, Callable
23
+
24
+ from . import __version__
25
+ from .core.config import (
26
+ wrap_conda_activation_command,
27
+ data_home,
28
+ default_socket_path,
29
+ parse_environment_spec,
30
+ wrap_conda_command,
31
+ )
32
+ from .core.inventory import Inventory
33
+ from .core.limits import (
34
+ DEFAULT_EXEC_TIMEOUT_SECONDS,
35
+ MAX_JSON_RPC_REQUEST_BYTES,
36
+ MAX_RPC_ERROR_BYTES,
37
+ MAX_RPC_RESPONSE_BYTES,
38
+ validate_exec_timeout_seconds,
39
+ )
40
+ from .core.resources import parse_variable_assignment
41
+ from .core.workspace.access import (
42
+ atomic_write,
43
+ delete_file,
44
+ glob_paths,
45
+ list_directory,
46
+ read_file,
47
+ resolve_path,
48
+ search,
49
+ stat_path,
50
+ workspace_root,
51
+ )
52
+
53
+
54
+ MAX_REQUEST_BYTES = MAX_JSON_RPC_REQUEST_BYTES
55
+ MAX_EXEC_OUTPUT_BYTES = 4 * 1024 * 1024
56
+ EXEC_PIPE_DRAIN_GRACE_SECONDS = 0.25
57
+ _EXEC_READ_CHUNK_BYTES = 64 * 1024
58
+ _EXEC_FORCE_CLEANUP_SECONDS = 0.25
59
+ _EXEC_THREAD_JOIN_SECONDS = 0.05
60
+
61
+
62
+ def _bounded_utf8(value: Any, maximum: int) -> str:
63
+ text = str(value)
64
+ retained = bytearray()
65
+ for offset in range(0, len(text), 4096):
66
+ chunk = text[offset : offset + 4096].encode(
67
+ "utf-8",
68
+ errors="replace",
69
+ )
70
+ remaining = maximum - len(retained)
71
+ if remaining <= 0:
72
+ break
73
+ retained.extend(chunk[:remaining])
74
+ return bytes(retained).decode("utf-8", errors="ignore")
75
+
76
+
77
+ def _encode_json_response(value: Any, maximum: int) -> bytes | None:
78
+ retained = bytearray()
79
+ encoder = json.JSONEncoder(ensure_ascii=False)
80
+ for fragment in encoder.iterencode(value):
81
+ encoded = fragment.encode("utf-8")
82
+ if len(retained) + len(encoded) + 1 > maximum:
83
+ return None
84
+ retained.extend(encoded)
85
+ retained.extend(b"\n")
86
+ return bytes(retained)
87
+
88
+
89
+ def _write_proxy_error(error_type: str, message: Any) -> None:
90
+ value = {
91
+ "id": None,
92
+ "error": {
93
+ "type": _bounded_utf8(error_type, 128),
94
+ "message": _bounded_utf8(message, MAX_RPC_ERROR_BYTES),
95
+ },
96
+ }
97
+ raw = _encode_json_response(value, MAX_RPC_RESPONSE_BYTES)
98
+ if raw is not None:
99
+ sys.stdout.buffer.write(raw)
100
+ sys.stdout.buffer.flush()
101
+
102
+
103
+ def _drain_exec_stream(
104
+ stream: Any,
105
+ retained: bytearray,
106
+ truncated: list[bool],
107
+ lock: threading.Lock,
108
+ done: threading.Event,
109
+ stream_name: str,
110
+ output_callback: Callable[[str, bytes], None] | None,
111
+ retain_output: bool,
112
+ ) -> None:
113
+ try:
114
+ while True:
115
+ try:
116
+ chunk = os.read(stream.fileno(), _EXEC_READ_CHUNK_BYTES)
117
+ except (OSError, ValueError):
118
+ return
119
+ if not chunk:
120
+ return
121
+ if output_callback is not None:
122
+ output_callback(stream_name, chunk)
123
+ if retain_output:
124
+ with lock:
125
+ remaining = MAX_EXEC_OUTPUT_BYTES - len(retained)
126
+ if remaining > 0:
127
+ retained.extend(chunk[:remaining])
128
+ if len(chunk) > remaining:
129
+ truncated[0] = True
130
+ finally:
131
+ try:
132
+ stream.close()
133
+ except (OSError, ValueError):
134
+ pass
135
+ done.set()
136
+
137
+
138
+ def _kill_exec_process_group(process: subprocess.Popen[bytes]) -> None:
139
+ """Kill only the isolated child group, never the daemon's process group."""
140
+ group_killed = False
141
+ if os.name == "posix" and hasattr(os, "killpg"):
142
+ child_group = process.pid
143
+ if child_group != os.getpgrp():
144
+ try:
145
+ os.killpg(child_group, signal.SIGKILL)
146
+ group_killed = True
147
+ except (ProcessLookupError, PermissionError):
148
+ pass
149
+ if not group_killed and process.poll() is None:
150
+ process.kill()
151
+
152
+
153
+ def _wait_for_exec_drainers(
154
+ done: list[threading.Event],
155
+ deadline: float,
156
+ ) -> bool:
157
+ for event in done:
158
+ if event.is_set():
159
+ continue
160
+ remaining = deadline - time.monotonic()
161
+ if remaining <= 0 or not event.wait(remaining):
162
+ return False
163
+ return True
164
+
165
+
166
+ def _close_exec_pipes(process: subprocess.Popen[bytes]) -> None:
167
+ for stream in (process.stdout, process.stderr):
168
+ if stream is None:
169
+ continue
170
+ try:
171
+ os.close(stream.fileno())
172
+ except (OSError, ValueError):
173
+ pass
174
+
175
+
176
+ def _reap_exec_process(
177
+ process: subprocess.Popen[bytes],
178
+ deadline: float,
179
+ ) -> None:
180
+ remaining = max(0.0, deadline - time.monotonic())
181
+ try:
182
+ process.wait(timeout=remaining)
183
+ except subprocess.TimeoutExpired:
184
+ if process.poll() is None:
185
+ process.kill()
186
+ remaining = max(0.0, deadline - time.monotonic())
187
+ try:
188
+ process.wait(timeout=remaining)
189
+ except subprocess.TimeoutExpired:
190
+ pass
191
+
192
+
193
+ def _execute_bounded(
194
+ command: list[str],
195
+ *,
196
+ cwd: str | None,
197
+ env: dict[str, str],
198
+ timeout: float | None,
199
+ cancellation_requested: Callable[[], bool] | None = None,
200
+ output_callback: Callable[[str, bytes], None] | None = None,
201
+ retain_output: bool = True,
202
+ ) -> tuple[int, str, str, bool, bool, bool, bool, bool]:
203
+ """Execute while retaining bounded output.
204
+
205
+ ``timeout=None`` intentionally permits long-running same-user commands.
206
+ Once the direct child exits, inherited output pipes receive only a bounded
207
+ drain grace so detached descendants cannot hold the RPC open forever.
208
+ """
209
+ started = time.monotonic()
210
+ deadline = started + timeout if timeout is not None else None
211
+ process = subprocess.Popen(
212
+ command,
213
+ cwd=cwd,
214
+ env=env,
215
+ stdout=subprocess.PIPE,
216
+ stderr=subprocess.PIPE,
217
+ start_new_session=(os.name == "posix"),
218
+ )
219
+ assert process.stdout is not None
220
+ assert process.stderr is not None
221
+ stdout = bytearray()
222
+ stderr = bytearray()
223
+ stdout_truncated = [False]
224
+ stderr_truncated = [False]
225
+ stdout_lock = threading.Lock()
226
+ stderr_lock = threading.Lock()
227
+ drain_done = [threading.Event(), threading.Event()]
228
+ drainers = [
229
+ threading.Thread(
230
+ target=_drain_exec_stream,
231
+ args=(
232
+ process.stdout,
233
+ stdout,
234
+ stdout_truncated,
235
+ stdout_lock,
236
+ drain_done[0],
237
+ "stdout",
238
+ output_callback,
239
+ retain_output,
240
+ ),
241
+ daemon=True,
242
+ ),
243
+ threading.Thread(
244
+ target=_drain_exec_stream,
245
+ args=(
246
+ process.stderr,
247
+ stderr,
248
+ stderr_truncated,
249
+ stderr_lock,
250
+ drain_done[1],
251
+ "stderr",
252
+ output_callback,
253
+ retain_output,
254
+ ),
255
+ daemon=True,
256
+ ),
257
+ ]
258
+ for drainer in drainers:
259
+ drainer.start()
260
+ timed_out = False
261
+ cancelled = False
262
+ output_drain_truncated = False
263
+ cleanup_deadline: float | None = None
264
+ try:
265
+ while True:
266
+ if (
267
+ cancellation_requested is not None
268
+ and cancellation_requested()
269
+ ):
270
+ cancelled = True
271
+ cleanup_deadline = (
272
+ time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
273
+ )
274
+ _kill_exec_process_group(process)
275
+ _reap_exec_process(process, cleanup_deadline)
276
+ returncode = 130
277
+ break
278
+ remaining = (
279
+ None
280
+ if deadline is None
281
+ else deadline - time.monotonic()
282
+ )
283
+ if remaining is not None and remaining <= 0:
284
+ timed_out = True
285
+ cleanup_deadline = (
286
+ time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
287
+ )
288
+ _kill_exec_process_group(process)
289
+ _reap_exec_process(process, cleanup_deadline)
290
+ returncode = 124
291
+ break
292
+ wait_timeout = remaining
293
+ if cancellation_requested is not None:
294
+ wait_timeout = (
295
+ 0.1
296
+ if remaining is None
297
+ else min(0.1, remaining)
298
+ )
299
+ try:
300
+ returncode = process.wait(timeout=wait_timeout)
301
+ break
302
+ except subprocess.TimeoutExpired:
303
+ if cancellation_requested is not None:
304
+ continue
305
+ timed_out = True
306
+ cleanup_deadline = (
307
+ time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
308
+ )
309
+ _kill_exec_process_group(process)
310
+ _reap_exec_process(process, cleanup_deadline)
311
+ returncode = 124
312
+ break
313
+
314
+ drain_deadline = time.monotonic() + EXEC_PIPE_DRAIN_GRACE_SECONDS
315
+ if deadline is not None:
316
+ drain_deadline = min(drain_deadline, deadline)
317
+ drained = all(event.is_set() for event in drain_done)
318
+ if not drained and drain_deadline > time.monotonic():
319
+ drained = _wait_for_exec_drainers(drain_done, drain_deadline)
320
+ if not drained:
321
+ output_drain_truncated = True
322
+ if deadline is not None and time.monotonic() >= deadline:
323
+ timed_out = True
324
+ returncode = 124
325
+ cleanup_deadline = cleanup_deadline or (
326
+ time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
327
+ )
328
+ _kill_exec_process_group(process)
329
+ _reap_exec_process(process, cleanup_deadline)
330
+ _close_exec_pipes(process)
331
+ _wait_for_exec_drainers(
332
+ drain_done,
333
+ cleanup_deadline,
334
+ )
335
+ except BaseException:
336
+ cleanup_deadline = cleanup_deadline or (
337
+ time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
338
+ )
339
+ _kill_exec_process_group(process)
340
+ _reap_exec_process(process, cleanup_deadline)
341
+ _close_exec_pipes(process)
342
+ raise
343
+ finally:
344
+ join_deadline = cleanup_deadline or (
345
+ time.monotonic() + _EXEC_THREAD_JOIN_SECONDS
346
+ )
347
+ for drainer in drainers:
348
+ drainer.join(max(0.0, join_deadline - time.monotonic()))
349
+
350
+ with stdout_lock:
351
+ stdout_value = bytes(stdout).decode("utf-8", errors="replace")
352
+ stdout_was_truncated = stdout_truncated[0]
353
+ with stderr_lock:
354
+ stderr_value = bytes(stderr).decode("utf-8", errors="replace")
355
+ stderr_was_truncated = stderr_truncated[0]
356
+ return (
357
+ returncode,
358
+ stdout_value,
359
+ stderr_value,
360
+ stdout_was_truncated,
361
+ stderr_was_truncated,
362
+ timed_out,
363
+ cancelled,
364
+ output_drain_truncated,
365
+ )
366
+
367
+
368
+ class RpcDispatcher:
369
+ def __init__(self, inventory: Inventory):
370
+ self.inventory = inventory
371
+ self._activation_cache: dict[
372
+ tuple[
373
+ str,
374
+ str | None,
375
+ tuple[tuple[str, str], ...],
376
+ tuple[tuple[str, str], ...],
377
+ ],
378
+ tuple[tuple[tuple[str, int | None, int | None], ...] | None, dict[str, str]],
379
+ ] = {}
380
+ self._activation_cache_lock = threading.Lock()
381
+
382
+ def dispatch(
383
+ self,
384
+ method: str,
385
+ params: dict[str, Any],
386
+ cancellation_requested: Callable[[], bool] | None = None,
387
+ ) -> Any:
388
+ if method == "exec":
389
+ return self.exec_command(params, cancellation_requested)
390
+ if method == "workspace.git_status":
391
+ return self.workspace_git_status(params, cancellation_requested)
392
+ if method == "workspace.git_diff":
393
+ return self.workspace_git_diff(params, cancellation_requested)
394
+ handlers = {
395
+ "ping": self.ping,
396
+ "node.info": self.node_info,
397
+ "npu.list": self.npu_list,
398
+ "model.register": self.model_register,
399
+ "model.discover": self.model_discover,
400
+ "model.list": self.model_list,
401
+ "model.inspect": self.model_inspect,
402
+ "model.update": self.model_update,
403
+ "model.delete": self.model_delete,
404
+ "model.restore": self.model_restore,
405
+ "model.refresh": self.model_refresh,
406
+ "model.validate": self.model_validate,
407
+ "model.audit": self.model_audit,
408
+ "model.verify": self.model_verify,
409
+ "model.unregister": self.model_unregister,
410
+ "replica.list": self.replica_list,
411
+ "replica.add": self.replica_add,
412
+ "replica.remove": self.replica_remove,
413
+ "model.export": self.model_export,
414
+ "model.import": self.model_import,
415
+ "model.rebind": self.model_rebind,
416
+ "exec": self.exec_command,
417
+ "workspace.stat": self.workspace_stat,
418
+ "workspace.read": self.workspace_read,
419
+ "workspace.list": self.workspace_list,
420
+ "workspace.glob": self.workspace_glob,
421
+ "workspace.search": self.workspace_search,
422
+ "workspace.write": self.workspace_write,
423
+ "workspace.delete": self.workspace_delete,
424
+ }
425
+ handler = handlers.get(method)
426
+ if handler is None:
427
+ raise KeyError(f"unknown RPC method: {method}")
428
+ return handler(params)
429
+
430
+ def ping(self, _params: dict[str, Any]) -> dict[str, Any]:
431
+ return {"ok": True, "version": __version__, "node_id": self.inventory.node_id}
432
+
433
+ @staticmethod
434
+ def _runtime(params: dict[str, Any]) -> dict[str, str]:
435
+ runtime = params.get("_runtime") or {"type": "host"}
436
+ if not isinstance(runtime, dict):
437
+ raise ValueError("runtime must be an object")
438
+ runtime_type = str(runtime.get("type", "host"))
439
+ if runtime_type == "host":
440
+ return {"type": "host"}
441
+ if runtime_type != "docker":
442
+ raise ValueError(f"unsupported runtime: {runtime_type}")
443
+ container = runtime.get("container")
444
+ if not isinstance(container, str) or not container:
445
+ raise ValueError("docker runtime requires a container name")
446
+ return {"type": "docker", "container": container}
447
+
448
+ @staticmethod
449
+ def _environments(params: dict[str, Any]) -> list[tuple[str, str]]:
450
+ values = params.get("_environments") or []
451
+ if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
452
+ raise ValueError("environments must be a string array")
453
+ return [parse_environment_spec(item) for item in values]
454
+
455
+ @staticmethod
456
+ def _node_environment(params: dict[str, Any]) -> dict[str, str]:
457
+ values = params.get("_node_env") or {}
458
+ if not isinstance(values, dict):
459
+ raise ValueError("node environment must be an object")
460
+ environment: dict[str, str] = {}
461
+ for key, value in values.items():
462
+ if not isinstance(key, str) or not isinstance(value, str):
463
+ raise ValueError(
464
+ "node environment names and values must be strings"
465
+ )
466
+ parsed_key, parsed_value = parse_variable_assignment(
467
+ f"{key}={value}"
468
+ )
469
+ environment[parsed_key] = parsed_value
470
+ return environment
471
+
472
+ @staticmethod
473
+ def _wrap_environment_command(
474
+ command: list[str],
475
+ environments: list[tuple[str, str]],
476
+ ) -> list[str]:
477
+ """Wrap an argv from the innermost environment to the outermost."""
478
+ wrapped = list(command)
479
+ for kind, value in reversed(environments):
480
+ if kind == "uv":
481
+ wrapped = ["uv", "run", "--project", value, "--", *wrapped]
482
+ elif kind == "venv":
483
+ activate = f"{value.rstrip('/')}/bin/activate"
484
+ script = f". {shlex.quote(activate)} && exec \"$@\""
485
+ wrapped = ["sh", "-c", script, "msdev-venv", *wrapped]
486
+ elif kind == "conda":
487
+ wrapped = wrap_conda_command(wrapped, value)
488
+ return wrapped
489
+
490
+ @staticmethod
491
+ def _wrap_activation_probe(
492
+ command: list[str],
493
+ environments: list[tuple[str, str]],
494
+ ) -> list[str]:
495
+ wrapped = list(command)
496
+ for kind, value in reversed(environments):
497
+ if kind == "uv":
498
+ wrapped = ["uv", "run", "--project", value, "--", *wrapped]
499
+ elif kind == "venv":
500
+ activate = f"{value.rstrip('/')}/bin/activate"
501
+ script = f". {shlex.quote(activate)} && exec \"$@\""
502
+ wrapped = ["sh", "-c", script, "msdev-venv", *wrapped]
503
+ elif kind == "conda":
504
+ wrapped = wrap_conda_activation_command(wrapped, value)
505
+ return wrapped
506
+
507
+ @staticmethod
508
+ def _activation_fingerprint(
509
+ runtime: dict[str, str],
510
+ environments: list[tuple[str, str]],
511
+ activated: dict[str, str],
512
+ ) -> tuple[tuple[str, int | None, int | None], ...] | None:
513
+ if runtime["type"] != "host":
514
+ return None
515
+ watched: set[Path] = set()
516
+ for kind, value in environments:
517
+ if kind == "conda":
518
+ prefix = (
519
+ value
520
+ if "/" in value
521
+ else activated.get("CONDA_PREFIX", "")
522
+ )
523
+ if prefix:
524
+ root = Path(prefix).expanduser()
525
+ watched.add(root / "conda-meta" / "history")
526
+ hooks = root / "etc" / "conda" / "activate.d"
527
+ watched.add(hooks)
528
+ if hooks.is_dir():
529
+ watched.update(hooks.iterdir())
530
+ elif kind == "venv":
531
+ root = Path(value).expanduser()
532
+ watched.add(root / "pyvenv.cfg")
533
+ watched.add(root / "bin" / "activate")
534
+ elif kind == "uv":
535
+ root = Path(value).expanduser()
536
+ watched.add(root / "pyproject.toml")
537
+ watched.add(root / "uv.lock")
538
+ watched.add(root / ".venv" / "pyvenv.cfg")
539
+ fingerprint: list[tuple[str, int | None, int | None]] = []
540
+ for path in sorted(watched, key=str):
541
+ try:
542
+ stat = path.stat()
543
+ fingerprint.append((str(path), stat.st_mtime_ns, stat.st_size))
544
+ except FileNotFoundError:
545
+ fingerprint.append((str(path), None, None))
546
+ return tuple(fingerprint)
547
+
548
+ def _load_activation_environment(
549
+ self,
550
+ runtime: dict[str, str],
551
+ environments: list[tuple[str, str]],
552
+ base_environment: dict[str, str],
553
+ ) -> dict[str, str]:
554
+ command = self._wrap_activation_probe(["env", "-0"], environments)
555
+ if runtime["type"] == "docker":
556
+ command = ["docker", "exec", runtime["container"], *command]
557
+ probe_environment = os.environ.copy()
558
+ probe_environment.update(base_environment)
559
+ (
560
+ returncode,
561
+ stdout,
562
+ stderr,
563
+ stdout_truncated,
564
+ stderr_truncated,
565
+ timed_out,
566
+ _cancelled,
567
+ output_drain_truncated,
568
+ ) = _execute_bounded(
569
+ command,
570
+ cwd=None,
571
+ env=probe_environment,
572
+ timeout=30.0,
573
+ )
574
+ if (
575
+ returncode != 0
576
+ or timed_out
577
+ or stdout_truncated
578
+ or stderr_truncated
579
+ or output_drain_truncated
580
+ ):
581
+ detail = stderr.strip() or f"exit code {returncode}"
582
+ raise RuntimeError(
583
+ f"failed to activate execution environment: {detail}"
584
+ )
585
+ activated: dict[str, str] = {}
586
+ for item in stdout.split("\0"):
587
+ key, separator, value = item.partition("=")
588
+ if separator and key and "\0" not in key:
589
+ activated[key] = value
590
+ for transient in ("_", "PWD", "OLDPWD", "SHLVL"):
591
+ activated.pop(transient, None)
592
+ if "PATH" not in activated:
593
+ raise RuntimeError("activated environment did not provide PATH")
594
+ return activated
595
+
596
+ def _activation_environment(
597
+ self,
598
+ runtime: dict[str, str],
599
+ environments: list[tuple[str, str]],
600
+ base_environment: dict[str, str] | None = None,
601
+ ) -> dict[str, str]:
602
+ if not environments:
603
+ return {}
604
+ base_environment = dict(base_environment or {})
605
+ key = (
606
+ runtime["type"],
607
+ runtime.get("container"),
608
+ tuple(environments),
609
+ tuple(sorted(base_environment.items())),
610
+ )
611
+ with self._activation_cache_lock:
612
+ cached = self._activation_cache.get(key)
613
+ if cached is not None:
614
+ fingerprint, activated = cached
615
+ current = self._activation_fingerprint(
616
+ runtime,
617
+ environments,
618
+ activated,
619
+ )
620
+ if current == fingerprint:
621
+ return dict(activated)
622
+ activated = self._load_activation_environment(
623
+ runtime,
624
+ environments,
625
+ base_environment,
626
+ )
627
+ fingerprint = self._activation_fingerprint(
628
+ runtime,
629
+ environments,
630
+ activated,
631
+ )
632
+ self._activation_cache[key] = (fingerprint, dict(activated))
633
+ return activated
634
+
635
+ @staticmethod
636
+ def _docker_info(container: str) -> dict[str, Any]:
637
+ try:
638
+ completed = subprocess.run(
639
+ ["docker", "inspect", container],
640
+ capture_output=True,
641
+ text=True,
642
+ timeout=20,
643
+ check=False,
644
+ )
645
+ except FileNotFoundError as exc:
646
+ raise RuntimeError("docker command not found on node host") from exc
647
+ if completed.returncode != 0:
648
+ raise RuntimeError(
649
+ completed.stderr.strip() or f"docker container not found: {container}"
650
+ )
651
+ values = json.loads(completed.stdout)
652
+ if not values:
653
+ raise RuntimeError(f"docker inspect returned no data for {container}")
654
+ value = values[0]
655
+ state = value.get("State") or {}
656
+ return {
657
+ "type": "docker",
658
+ "container": container,
659
+ "id": str(value.get("Id", ""))[:12],
660
+ "image": (value.get("Config") or {}).get("Image"),
661
+ "status": state.get("Status"),
662
+ "running": bool(state.get("Running")),
663
+ "pid": state.get("Pid"),
664
+ }
665
+
666
+ def node_info(self, params: dict[str, Any]) -> dict[str, Any]:
667
+ usage = shutil.disk_usage(self.inventory.data_dir)
668
+ value = {
669
+ "node_id": self.inventory.node_id,
670
+ "hostname": platform.node(),
671
+ "architecture": platform.machine(),
672
+ "platform": platform.platform(),
673
+ "python": platform.python_version(),
674
+ "daemon_version": __version__,
675
+ "uid": os.getuid(),
676
+ "data_dir": str(self.inventory.data_dir),
677
+ "disk": {
678
+ "total": usage.total,
679
+ "used": usage.used,
680
+ "free": usage.free,
681
+ },
682
+ }
683
+ runtime = self._runtime(params)
684
+ environments = self._environments(params)
685
+ value["runtime"] = (
686
+ self._docker_info(runtime["container"])
687
+ if runtime["type"] == "docker"
688
+ else runtime
689
+ )
690
+ value["environments"] = [
691
+ {"type": kind, "value": item}
692
+ for kind, item in environments
693
+ ]
694
+ return value
695
+
696
+ def npu_list(self, params: dict[str, Any]) -> dict[str, Any]:
697
+ runtime = self._runtime(params)
698
+ environments = self._environments(params)
699
+ node_environment = self._node_environment(params)
700
+ command = self._wrap_environment_command(
701
+ ["npu-smi", "info"],
702
+ environments,
703
+ )
704
+ if runtime["type"] == "docker":
705
+ command = ["docker", "exec", runtime["container"], *command]
706
+ run_environment = os.environ.copy()
707
+ if runtime["type"] == "host":
708
+ run_environment.update(node_environment)
709
+ try:
710
+ completed = subprocess.run(
711
+ command,
712
+ capture_output=True,
713
+ text=True,
714
+ timeout=20,
715
+ check=False,
716
+ env=run_environment,
717
+ )
718
+ except FileNotFoundError:
719
+ return {
720
+ "available": False,
721
+ "reason": f"{command[0]} not found",
722
+ "output": "",
723
+ "runtime": runtime,
724
+ "environments": environments,
725
+ }
726
+ except subprocess.TimeoutExpired:
727
+ return {
728
+ "available": False,
729
+ "reason": "npu-smi timed out",
730
+ "output": "",
731
+ "runtime": runtime,
732
+ "environments": environments,
733
+ }
734
+ output = (completed.stdout or "") + (completed.stderr or "")
735
+ return {
736
+ "available": completed.returncode == 0,
737
+ "returncode": completed.returncode,
738
+ "output": output,
739
+ "runtime": runtime,
740
+ "environments": environments,
741
+ }
742
+
743
+ def model_register(self, params: dict[str, Any]) -> dict[str, Any]:
744
+ return self.inventory.register_model(
745
+ ref=str(params["ref"]),
746
+ path=str(params["path"]),
747
+ scope=str(params.get("scope", "user")),
748
+ format_name=params.get("format"),
749
+ full_checksum=bool(params.get("full_checksum", False)),
750
+ )
751
+
752
+ def model_discover(self, params: dict[str, Any]) -> dict[str, Any]:
753
+ return self.inventory.discover_models(
754
+ root=str(params["root"]),
755
+ max_depth=int(params.get("max_depth", 5)),
756
+ register=bool(params.get("register", False)),
757
+ namespace=str(params.get("namespace", "discovered")),
758
+ )
759
+
760
+ def model_list(self, params: dict[str, Any]) -> list[dict[str, Any]]:
761
+ return self.inventory.list_models(
762
+ format_name=params.get("format"),
763
+ model_type=params.get("model_type"),
764
+ state=params.get("state"),
765
+ tag=params.get("tag"),
766
+ name=params.get("name"),
767
+ include_deleted=bool(params.get("include_deleted", False)),
768
+ )
769
+
770
+ def model_inspect(self, params: dict[str, Any]) -> dict[str, Any]:
771
+ return self.inventory.inspect_model(str(params["ref"]))
772
+
773
+ def model_update(self, params: dict[str, Any]) -> dict[str, Any]:
774
+ return self.inventory.update_model(
775
+ str(params["ref"]),
776
+ add_aliases=params.get("add_aliases") or [],
777
+ remove_aliases=params.get("remove_aliases") or [],
778
+ add_tags=params.get("add_tags") or [],
779
+ remove_tags=params.get("remove_tags") or [],
780
+ description=params.get("description"),
781
+ clear_description=bool(params.get("clear_description", False)),
782
+ )
783
+
784
+ def model_delete(self, params: dict[str, Any]) -> dict[str, Any]:
785
+ return self.inventory.delete_model(str(params["ref"]))
786
+
787
+ def model_restore(self, params: dict[str, Any]) -> dict[str, Any]:
788
+ return self.inventory.restore_model(str(params["ref"]))
789
+
790
+ def model_refresh(self, params: dict[str, Any]) -> dict[str, Any]:
791
+ return self.inventory.refresh_model(
792
+ str(params["ref"]),
793
+ path=params.get("path"),
794
+ full_checksum=bool(params.get("full_checksum", False)),
795
+ )
796
+
797
+ def model_validate(self, params: dict[str, Any]) -> dict[str, Any]:
798
+ return self.inventory.validate_model(
799
+ str(params["ref"]),
800
+ path=params.get("path"),
801
+ )
802
+
803
+ def model_audit(self, params: dict[str, Any]) -> list[dict[str, Any]]:
804
+ return self.inventory.audit_records(
805
+ ref=params.get("ref"),
806
+ limit=int(params.get("limit", 100)),
807
+ )
808
+
809
+ def model_verify(self, params: dict[str, Any]) -> dict[str, Any]:
810
+ return self.inventory.verify_model(
811
+ str(params["ref"]),
812
+ full_checksum=bool(params.get("full_checksum", False)),
813
+ )
814
+
815
+ def model_unregister(self, params: dict[str, Any]) -> dict[str, Any]:
816
+ return self.inventory.unregister_model(
817
+ str(params["ref"]),
818
+ path=params.get("path"),
819
+ )
820
+
821
+ def replica_list(self, params: dict[str, Any]) -> dict[str, Any]:
822
+ return self.inventory.list_replicas(str(params["ref"]))
823
+
824
+ def replica_add(self, params: dict[str, Any]) -> dict[str, Any]:
825
+ model = self.inventory.inspect_model(str(params["ref"]))
826
+ return self.inventory.register_model(
827
+ ref=model["ref"],
828
+ path=str(params["path"]),
829
+ format_name=model.get("format"),
830
+ full_checksum=bool(params.get("full_checksum", False)),
831
+ )
832
+
833
+ def replica_remove(self, params: dict[str, Any]) -> dict[str, Any]:
834
+ return self.inventory.remove_replica(
835
+ str(params["ref"]),
836
+ str(params["path"]),
837
+ )
838
+
839
+ def model_export(self, _params: dict[str, Any]) -> dict[str, Any]:
840
+ return self.inventory.export_records()
841
+
842
+ def model_import(self, params: dict[str, Any]) -> dict[str, Any]:
843
+ root_maps = [tuple(item) for item in params.get("root_maps", [])]
844
+ return self.inventory.import_records(params["payload"], root_maps=root_maps)
845
+
846
+ def model_rebind(self, params: dict[str, Any]) -> dict[str, Any]:
847
+ return self.inventory.register_model(
848
+ ref=str(params["ref"]),
849
+ path=str(params["path"]),
850
+ scope=str(params.get("scope", "user")),
851
+ full_checksum=bool(params.get("full_checksum", False)),
852
+ )
853
+
854
+ def workspace_stat(self, params: dict[str, Any]) -> dict[str, Any]:
855
+ return stat_path(params.get("root"), params.get("path", "."))
856
+
857
+ def workspace_read(self, params: dict[str, Any]) -> dict[str, Any]:
858
+ return read_file(
859
+ params.get("root"),
860
+ params.get("path"),
861
+ params.get("max_bytes", 1024 * 1024),
862
+ )
863
+
864
+ def workspace_list(self, params: dict[str, Any]) -> dict[str, Any]:
865
+ return list_directory(params.get("root"), params.get("path", "."))
866
+
867
+ def workspace_glob(self, params: dict[str, Any]) -> dict[str, Any]:
868
+ return glob_paths(
869
+ params.get("root"),
870
+ params.get("pattern"),
871
+ params.get("max_results", 1000),
872
+ )
873
+
874
+ def workspace_search(self, params: dict[str, Any]) -> dict[str, Any]:
875
+ return search(
876
+ params.get("root"),
877
+ params.get("pattern"),
878
+ params.get("paths"),
879
+ params.get("glob"),
880
+ params.get("max_results", 1000),
881
+ )
882
+
883
+ def workspace_write(self, params: dict[str, Any]) -> dict[str, Any]:
884
+ return atomic_write(
885
+ params.get("root"),
886
+ params.get("path"),
887
+ params.get("content_base64"),
888
+ params.get("expected_sha256"),
889
+ )
890
+
891
+ def workspace_delete(self, params: dict[str, Any]) -> dict[str, Any]:
892
+ return delete_file(params.get("root"), params.get("path"))
893
+
894
+ def _workspace_git(
895
+ self,
896
+ params: dict[str, Any],
897
+ command: list[str],
898
+ cancellation_requested: Callable[[], bool] | None = None,
899
+ ) -> dict[str, Any]:
900
+ root = workspace_root(params.get("root"))
901
+ runtime = self._runtime(params)
902
+ execution_root = params.get("execution_root")
903
+ if not isinstance(execution_root, str) or not execution_root:
904
+ execution_root = str(root)
905
+ if runtime["type"] == "host":
906
+ candidate = Path(execution_root).expanduser()
907
+ if candidate.is_absolute():
908
+ execution_path = candidate.resolve(strict=True)
909
+ try:
910
+ execution_path.relative_to(root)
911
+ except ValueError as exc:
912
+ raise PermissionError(
913
+ "host workspace execution root must stay under workspace root"
914
+ ) from exc
915
+ else:
916
+ _, execution_path = resolve_path(str(root), execution_root)
917
+ if not execution_path.is_dir():
918
+ raise NotADirectoryError(
919
+ f"workspace execution root is not a directory: {execution_path}"
920
+ )
921
+ execution_root = str(execution_path)
922
+ forwarded = dict(params)
923
+ forwarded["cwd"] = execution_root
924
+ forwarded["command"] = command
925
+ forwarded["env"] = {}
926
+ forwarded["timeout_seconds"] = min(
927
+ DEFAULT_EXEC_TIMEOUT_SECONDS,
928
+ 60.0,
929
+ )
930
+ result = self.exec_command(forwarded, cancellation_requested)
931
+ result["workspace_root"] = str(root)
932
+ result["execution_root"] = execution_root
933
+ return result
934
+
935
+ def workspace_git_status(
936
+ self,
937
+ params: dict[str, Any],
938
+ cancellation_requested: Callable[[], bool] | None = None,
939
+ ) -> dict[str, Any]:
940
+ return self._workspace_git(
941
+ params,
942
+ ["git", "status", "--short", "--branch"],
943
+ cancellation_requested,
944
+ )
945
+
946
+ def workspace_git_diff(
947
+ self,
948
+ params: dict[str, Any],
949
+ cancellation_requested: Callable[[], bool] | None = None,
950
+ ) -> dict[str, Any]:
951
+ argv = params.get("argv") or []
952
+ if not isinstance(argv, list) or not all(
953
+ isinstance(item, str) for item in argv
954
+ ):
955
+ raise ValueError("workspace Git diff argv must be a string array")
956
+ return self._workspace_git(
957
+ params,
958
+ ["git", "diff", *argv],
959
+ cancellation_requested,
960
+ )
961
+
962
+ def exec_command(
963
+ self,
964
+ params: dict[str, Any],
965
+ cancellation_requested: Callable[[], bool] | None = None,
966
+ output_callback: Callable[[str, bytes], None] | None = None,
967
+ retain_output: bool = True,
968
+ ) -> dict[str, Any]:
969
+ command = params.get("command")
970
+ if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
971
+ raise ValueError("exec command must be a non-empty string array")
972
+ runtime = self._runtime(params)
973
+ environments = self._environments(params)
974
+ node_environment = self._node_environment(params)
975
+ cwd = params.get("cwd")
976
+ if cwd is not None and runtime["type"] == "host":
977
+ cwd = str(Path(str(cwd)).expanduser().resolve(strict=True))
978
+ additions = params.get("env") or {}
979
+ if not isinstance(additions, dict):
980
+ raise ValueError("exec env must be an object")
981
+ additions = {str(key): str(value) for key, value in additions.items()}
982
+ if "timeout" in params:
983
+ raise ValueError("timeout is unsupported; use timeout_seconds")
984
+ timeout_seconds = validate_exec_timeout_seconds(params.get(
985
+ "timeout_seconds",
986
+ DEFAULT_EXEC_TIMEOUT_SECONDS,
987
+ ))
988
+ timeout = None if timeout_seconds == -1 else timeout_seconds
989
+ run_command = list(command)
990
+ run_cwd = cwd
991
+ run_env = os.environ.copy()
992
+ if runtime["type"] == "docker":
993
+ docker_info = self._docker_info(runtime["container"])
994
+ if not docker_info["running"]:
995
+ raise RuntimeError(
996
+ f"docker container is not running: {runtime['container']}"
997
+ )
998
+ activated = self._activation_environment(
999
+ runtime,
1000
+ environments,
1001
+ )
1002
+ run_command = ["docker", "exec"]
1003
+ if cwd:
1004
+ run_command += ["--workdir", str(cwd)]
1005
+ for key, value in activated.items():
1006
+ run_command += ["--env", f"{key}={value}"]
1007
+ for key, value in additions.items():
1008
+ run_command += ["--env", f"{key}={value}"]
1009
+ run_command += [
1010
+ runtime["container"],
1011
+ *command,
1012
+ ]
1013
+ run_cwd = None
1014
+ else:
1015
+ run_env.update(node_environment)
1016
+ run_env.update(
1017
+ self._activation_environment(
1018
+ runtime,
1019
+ environments,
1020
+ node_environment,
1021
+ )
1022
+ )
1023
+ run_env.update(additions)
1024
+ (
1025
+ returncode,
1026
+ stdout,
1027
+ stderr,
1028
+ stdout_truncated,
1029
+ stderr_truncated,
1030
+ timed_out,
1031
+ cancelled,
1032
+ output_drain_truncated,
1033
+ ) = _execute_bounded(
1034
+ run_command,
1035
+ cwd=run_cwd,
1036
+ env=run_env,
1037
+ timeout=timeout,
1038
+ cancellation_requested=cancellation_requested,
1039
+ output_callback=output_callback,
1040
+ retain_output=retain_output,
1041
+ )
1042
+ result: dict[str, Any] = {
1043
+ "returncode": returncode,
1044
+ "output_drain_truncated": output_drain_truncated,
1045
+ "cwd": cwd,
1046
+ "runtime": runtime,
1047
+ "environments": environments,
1048
+ }
1049
+ if retain_output:
1050
+ result.update(
1051
+ {
1052
+ "stdout": stdout,
1053
+ "stderr": stderr,
1054
+ "stdout_truncated": stdout_truncated,
1055
+ "stderr_truncated": stderr_truncated,
1056
+ }
1057
+ )
1058
+ if timed_out:
1059
+ result["timed_out"] = True
1060
+ if cancelled:
1061
+ result["cancelled"] = True
1062
+ return result
1063
+
1064
+
1065
+ class RpcHandler(socketserver.StreamRequestHandler):
1066
+ def _connection_closed(self) -> bool:
1067
+ try:
1068
+ readable, _, _ = select.select([self.connection], [], [], 0)
1069
+ if not readable:
1070
+ return False
1071
+ return (
1072
+ self.connection.recv(
1073
+ 1,
1074
+ socket.MSG_PEEK | socket.MSG_DONTWAIT,
1075
+ )
1076
+ == b""
1077
+ )
1078
+ except BlockingIOError:
1079
+ return False
1080
+ except OSError:
1081
+ return True
1082
+
1083
+ def _handle_exec_stream(
1084
+ self,
1085
+ request_id: Any,
1086
+ params: dict[str, Any],
1087
+ ) -> None:
1088
+ write_lock = threading.Lock()
1089
+ writer_failed = threading.Event()
1090
+ sequence = [0]
1091
+
1092
+ def emit(event: dict[str, Any]) -> None:
1093
+ with write_lock:
1094
+ if writer_failed.is_set():
1095
+ return
1096
+ sequence[0] += 1
1097
+ value = {
1098
+ "id": request_id,
1099
+ "seq": sequence[0],
1100
+ **event,
1101
+ }
1102
+ raw = _encode_json_response(value, MAX_RPC_RESPONSE_BYTES)
1103
+ if raw is None:
1104
+ writer_failed.set()
1105
+ return
1106
+ try:
1107
+ self.wfile.write(raw)
1108
+ self.wfile.flush()
1109
+ except (BrokenPipeError, ConnectionResetError, OSError):
1110
+ writer_failed.set()
1111
+
1112
+ def output_callback(stream_name: str, chunk: bytes) -> None:
1113
+ emit(
1114
+ {
1115
+ "event": stream_name,
1116
+ "data_base64": base64.b64encode(chunk).decode("ascii"),
1117
+ }
1118
+ )
1119
+
1120
+ def cancellation_requested() -> bool:
1121
+ return writer_failed.is_set() or self._connection_closed()
1122
+
1123
+ try:
1124
+ result = self.server.dispatcher.exec_command( # type: ignore[attr-defined]
1125
+ params,
1126
+ cancellation_requested,
1127
+ output_callback,
1128
+ False,
1129
+ )
1130
+ emit({"event": "result", "result": result})
1131
+ except Exception as exc:
1132
+ emit(
1133
+ {
1134
+ "event": "error",
1135
+ "error": {
1136
+ "type": type(exc).__name__,
1137
+ "message": _bounded_utf8(exc, MAX_RPC_ERROR_BYTES),
1138
+ },
1139
+ }
1140
+ )
1141
+
1142
+ def handle(self) -> None:
1143
+ raw = self.rfile.readline(MAX_REQUEST_BYTES + 1)
1144
+ if not raw:
1145
+ return
1146
+ if len(raw) > MAX_REQUEST_BYTES:
1147
+ self._write({"id": None, "error": {"type": "RequestTooLarge", "message": "request too large"}})
1148
+ return
1149
+ request_id = None
1150
+ try:
1151
+ request = json.loads(raw)
1152
+ request_id = request.get("id")
1153
+ method = request.get("method")
1154
+ params = request.get("params") or {}
1155
+ if not isinstance(method, str) or not isinstance(params, dict):
1156
+ raise ValueError("request requires string method and object params")
1157
+ if method == "exec.stream":
1158
+ self._handle_exec_stream(request_id, params)
1159
+ return
1160
+ result = self.server.dispatcher.dispatch( # type: ignore[attr-defined]
1161
+ method,
1162
+ params,
1163
+ self._connection_closed,
1164
+ )
1165
+ response = {"id": request_id, "result": result}
1166
+ except Exception as exc: # RPC boundary must return structured errors.
1167
+ response = {
1168
+ "id": request_id,
1169
+ "error": {
1170
+ "type": type(exc).__name__,
1171
+ "message": str(exc),
1172
+ },
1173
+ }
1174
+ if os.environ.get("MSDEVD_DEBUG") == "1":
1175
+ traceback.print_exc(file=sys.stderr)
1176
+ self._write(response)
1177
+
1178
+ def _write(self, value: dict[str, Any]) -> None:
1179
+ prepared = dict(value)
1180
+ error = prepared.get("error")
1181
+ if isinstance(error, dict):
1182
+ bounded_error = dict(error)
1183
+ bounded_error["message"] = _bounded_utf8(
1184
+ bounded_error.get("message", ""),
1185
+ MAX_RPC_ERROR_BYTES,
1186
+ )
1187
+ prepared["error"] = bounded_error
1188
+
1189
+ raw = _encode_json_response(prepared, MAX_RPC_RESPONSE_BYTES)
1190
+ if raw is None:
1191
+ raw = _encode_json_response(
1192
+ {
1193
+ "id": prepared.get("id"),
1194
+ "error": {
1195
+ "type": "ResponseTooLarge",
1196
+ "message": "RPC response exceeded the configured byte limit",
1197
+ },
1198
+ },
1199
+ MAX_RPC_RESPONSE_BYTES,
1200
+ )
1201
+ if raw is None:
1202
+ return
1203
+ try:
1204
+ self.wfile.write(raw)
1205
+ self.wfile.flush()
1206
+ except (BrokenPipeError, ConnectionResetError, OSError):
1207
+ return
1208
+
1209
+
1210
+ class ThreadingUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
1211
+ daemon_threads = True
1212
+
1213
+ def __init__(self, path: str, dispatcher: RpcDispatcher):
1214
+ self.dispatcher = dispatcher
1215
+ super().__init__(path, RpcHandler)
1216
+
1217
+
1218
+ def serve(socket_path: Path, daemon_data_home: Path) -> None:
1219
+ socket_path = socket_path.expanduser()
1220
+ socket_path.parent.mkdir(parents=True, exist_ok=True)
1221
+ if socket_path.exists():
1222
+ try:
1223
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
1224
+ probe.settimeout(0.5)
1225
+ probe.connect(str(socket_path))
1226
+ raise RuntimeError(f"daemon already running at {socket_path}")
1227
+ except (ConnectionRefusedError, FileNotFoundError, socket.timeout):
1228
+ socket_path.unlink(missing_ok=True)
1229
+
1230
+ inventory = Inventory(daemon_data_home)
1231
+ server = ThreadingUnixServer(str(socket_path), RpcDispatcher(inventory))
1232
+ os.chmod(socket_path, 0o600)
1233
+ try:
1234
+ server.serve_forever()
1235
+ finally:
1236
+ server.server_close()
1237
+ socket_path.unlink(missing_ok=True)
1238
+
1239
+
1240
+ def proxy_rpc(socket_path: Path) -> int:
1241
+ raw = sys.stdin.buffer.readline(MAX_REQUEST_BYTES + 1)
1242
+ if not raw:
1243
+ print(json.dumps({"id": None, "error": {"type": "EmptyRequest", "message": "empty request"}}))
1244
+ return 2
1245
+ try:
1246
+ request = json.loads(raw)
1247
+ cancel_on_disconnect = (
1248
+ isinstance(request, dict)
1249
+ and request.get("cancel_on_disconnect") is True
1250
+ )
1251
+ stream_response = (
1252
+ isinstance(request, dict)
1253
+ and request.get("method") == "exec.stream"
1254
+ )
1255
+ except (json.JSONDecodeError, UnicodeDecodeError):
1256
+ cancel_on_disconnect = False
1257
+ stream_response = False
1258
+ try:
1259
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
1260
+ client.connect(str(socket_path.expanduser()))
1261
+ client.sendall(raw if raw.endswith(b"\n") else raw + b"\n")
1262
+ response = bytearray()
1263
+ while stream_response or not response.endswith(b"\n"):
1264
+ if cancel_on_disconnect:
1265
+ readable, _, _ = select.select(
1266
+ [client, sys.stdin.buffer],
1267
+ [],
1268
+ [],
1269
+ )
1270
+ if sys.stdin.buffer in readable:
1271
+ if os.read(sys.stdin.fileno(), 1) == b"":
1272
+ return 130
1273
+ continue
1274
+ chunk = client.recv(65536)
1275
+ if not chunk:
1276
+ break
1277
+ if stream_response:
1278
+ try:
1279
+ sys.stdout.buffer.write(chunk)
1280
+ sys.stdout.buffer.flush()
1281
+ except (BrokenPipeError, OSError):
1282
+ return 130
1283
+ continue
1284
+ if len(response) + len(chunk) > MAX_RPC_RESPONSE_BYTES:
1285
+ _write_proxy_error(
1286
+ "ResponseTooLarge",
1287
+ "RPC response exceeded the configured byte limit",
1288
+ )
1289
+ return 1
1290
+ response.extend(chunk)
1291
+ except OSError as exc:
1292
+ _write_proxy_error(type(exc).__name__, exc)
1293
+ return 1
1294
+ if not stream_response:
1295
+ sys.stdout.buffer.write(bytes(response))
1296
+ sys.stdout.buffer.flush()
1297
+ return 0
1298
+
1299
+
1300
+ def build_parser() -> argparse.ArgumentParser:
1301
+ parser = argparse.ArgumentParser(prog="msdevd", description="User-level msdev node daemon")
1302
+ sub = parser.add_subparsers(dest="command", required=True)
1303
+
1304
+ serve_parser = sub.add_parser("serve", help="run the Unix-socket daemon")
1305
+ serve_parser.add_argument("--socket", type=Path, default=default_socket_path())
1306
+ serve_parser.add_argument("--data-dir", type=Path, default=data_home())
1307
+
1308
+ rpc_parser = sub.add_parser("rpc", help="proxy one JSON RPC request over stdin/stdout")
1309
+ rpc_parser.add_argument("--socket", type=Path, default=default_socket_path())
1310
+ return parser
1311
+
1312
+
1313
+ def main() -> None:
1314
+ args = build_parser().parse_args()
1315
+ if args.command == "serve":
1316
+ serve(args.socket, args.data_dir)
1317
+ elif args.command == "rpc":
1318
+ raise SystemExit(proxy_rpc(args.socket))
1319
+
1320
+
1321
+ if __name__ == "__main__":
1322
+ main()