-
Notifications
You must be signed in to change notification settings - Fork 804
Yimo/feat/host fragment #3967
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
poorwym
wants to merge
15
commits into
main
Choose a base branch
from
yimo/feat/host_fragment
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Yimo/feat/host fragment #3967
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
b6deb60
add architecture doc
92f1a86
add hosted spec
7e27056
add basic python functions
2455dce
add zenoh namespace and host client/server rpc control method
12a08ae
add docs
5eaf28a
implement basic host daemon
004f4c5
remove runtime error
e60769a
add dimos host cli
53ad026
use file lock to achieve global single instance
e1ad2ea
remove 2 Chinese docs
09a9de8
remove blueprint single instance lock
1444308
add basic implementation
f616bfc
add cross host rpc calling capability
9b6875f
add zenoh run coordinator rpc name channel
bd34fe4
add .hosted() api for auto placement
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,367 @@ | ||
| # Copyright 2026 Dimensional Inc. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Commands for running and inspecting DimOS Host services.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Callable, Iterator | ||
| from contextlib import ExitStack, contextmanager | ||
| import fcntl | ||
| from importlib.metadata import version as package_version | ||
| import json | ||
| from pathlib import Path | ||
| import threading | ||
| from typing import TYPE_CHECKING, Any, Never, TextIO | ||
| import uuid | ||
|
|
||
| import typer | ||
|
|
||
| from dimos.constants import STATE_DIR | ||
| from dimos.core.global_config import global_config | ||
|
|
||
| if TYPE_CHECKING: | ||
| from dimos.hosted.daemon import HostDescriptor | ||
| from dimos.protocol.rpc.zenohrpc import ZenohRPC | ||
|
|
||
| host_app = typer.Typer(help="Run and inspect DimOS Hosts", no_args_is_help=True) | ||
| HOST_ID_PATH = STATE_DIR / "hosted" / "host_id" | ||
| HOST_LOCK_PATH = STATE_DIR / "hosted" / "host.lock" | ||
| DISCOVERY_KEY = "dimos/hosts/*/live" | ||
| DEFAULT_DISCOVERY_TIMEOUT = 2.0 | ||
|
|
||
|
|
||
| def _load_host_id(path: Path) -> str: | ||
| try: | ||
| host_id = path.read_text().strip() | ||
| except FileNotFoundError: | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| host_id = uuid.uuid4().hex | ||
| try: | ||
| with path.open("x") as identity_file: | ||
| identity_file.write(f"{host_id}\n") | ||
| except FileExistsError: | ||
| host_id = path.read_text().strip() | ||
| if not host_id: | ||
| raise ValueError(f"Host identity file is empty: {path}") | ||
| return host_id | ||
|
|
||
|
|
||
| def _acquire_host_lock(path: Path) -> TextIO: | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| lock_file = path.open("a+") | ||
| try: | ||
| fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) | ||
| except BlockingIOError as exc: | ||
| lock_file.close() | ||
| raise RuntimeError("Host service is already running on this machine") from exc | ||
| return lock_file | ||
|
|
||
|
|
||
| def _zenoh_kwargs() -> dict[str, Any]: | ||
| return { | ||
| "mode": global_config.zenoh_mode, | ||
| "connect": [ | ||
| item.strip() for item in global_config.zenoh_connect.split(",") if item.strip() | ||
| ], | ||
| "scouting": global_config.zenoh_scouting, | ||
| "scouting_interface": global_config.zenoh_interface, | ||
| "multicast": global_config.zenoh_multicast, | ||
| "gossip": global_config.zenoh_gossip, | ||
| "connect_timeout": global_config.zenoh_connect_timeout, | ||
| } | ||
|
|
||
|
|
||
| @contextmanager | ||
| def _host_rpc() -> Iterator[ZenohRPC]: | ||
| from dimos.protocol.rpc.zenohrpc import ZenohRPC | ||
| from dimos.protocol.service.zenohservice import ZenohSessionPool | ||
|
|
||
| pool = ZenohSessionPool() | ||
| rpc = ZenohRPC(session_pool=pool, **_zenoh_kwargs()) | ||
| with ExitStack() as cleanup: | ||
| cleanup.callback(pool.close_all) | ||
| rpc.start() | ||
| cleanup.callback(rpc.stop) | ||
| yield rpc | ||
|
|
||
|
|
||
| def _discover_host_ids(rpc: ZenohRPC, timeout: float) -> list[str]: | ||
| replies = rpc.session.liveliness().get(DISCOVERY_KEY, timeout=timeout) | ||
| host_ids: set[str] = set() | ||
| for reply in replies: | ||
| sample = reply.ok | ||
| if sample is None: | ||
| continue | ||
| key = str(sample.key_expr) | ||
| parts = key.split("/") | ||
| if len(parts) == 4 and parts[:2] == ["dimos", "hosts"] and parts[3] == "live": | ||
| host_ids.add(parts[2]) | ||
| return sorted(host_ids) | ||
|
|
||
|
|
||
| def _get_descriptor(rpc: ZenohRPC, host_id: str, timeout: float) -> HostDescriptor: | ||
| from dimos.hosted.daemon import HOST_CONTROL_RPC_NAME, HostDescriptor | ||
|
|
||
| control_name = HOST_CONTROL_RPC_NAME.format(host_id=host_id) | ||
| result, unsubscribe = rpc.call_sync( | ||
| f"{control_name}/describe", | ||
| ([], {}), | ||
| rpc_timeout=timeout, | ||
| ) | ||
| try: | ||
| if not isinstance(result, HostDescriptor): | ||
| raise TypeError(f"Host {host_id} returned an invalid descriptor") | ||
| return result | ||
| finally: | ||
| unsubscribe() | ||
|
|
||
|
|
||
| def _descriptor_dict(descriptor: HostDescriptor) -> dict[str, Any]: | ||
| return { | ||
| "host_id": descriptor.host_id, | ||
| "epoch": descriptor.epoch, | ||
| "name": descriptor.name, | ||
| "tags": sorted(descriptor.tags), | ||
| "versions": descriptor.versions, | ||
| "state": descriptor.state, | ||
| "active_run_ids": list(descriptor.active_run_ids), | ||
| } | ||
|
|
||
|
|
||
| def _format_table(headers: tuple[str, ...], rows: list[tuple[str, ...]]) -> str: | ||
| widths = [ | ||
| max(len(header), *(len(row[index]) for row in rows)) for index, header in enumerate(headers) | ||
| ] | ||
|
|
||
| def format_row(row: tuple[str, ...]) -> str: | ||
| return " ".join(value.ljust(widths[index]) for index, value in enumerate(row)).rstrip() | ||
|
|
||
| return "\n".join( | ||
| ( | ||
| format_row(headers), | ||
| format_row(tuple("-" * width for width in widths)), | ||
| *(format_row(row) for row in rows), | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| def _fail(message: str) -> Never: | ||
| typer.echo(f"Error: {message}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
|
|
||
| @host_app.command("id") | ||
| def host_id() -> None: | ||
| """Show this machine's persistent Host ID.""" | ||
| try: | ||
| typer.echo(_load_host_id(HOST_ID_PATH)) | ||
| except (OSError, ValueError) as exc: | ||
| _fail(str(exc)) | ||
|
|
||
|
|
||
| @host_app.command("list") | ||
| def list_hosts( | ||
| json_output: bool = typer.Option(False, "--json", help="Output descriptors as JSON"), | ||
| timeout: float = typer.Option( | ||
| DEFAULT_DISCOVERY_TIMEOUT, | ||
| "--timeout", | ||
| min=0.1, | ||
| help="Discovery and RPC timeout in seconds", | ||
| ), | ||
| ) -> None: | ||
| """List Hosts currently visible through Zenoh liveliness.""" | ||
| try: | ||
| with _host_rpc() as rpc: | ||
| host_ids = _discover_host_ids(rpc, timeout) | ||
| descriptors: list[HostDescriptor | dict[str, str]] = [] | ||
| for discovered_id in host_ids: | ||
| try: | ||
| descriptors.append(_get_descriptor(rpc, discovered_id, timeout)) | ||
| except Exception as exc: | ||
| descriptors.append({"host_id": discovered_id, "error": str(exc)}) | ||
| except Exception as exc: | ||
| _fail(str(exc)) | ||
|
|
||
| if json_output: | ||
| output = [ | ||
| item if isinstance(item, dict) else _descriptor_dict(item) for item in descriptors | ||
| ] | ||
| typer.echo(json.dumps(output, indent=2, sort_keys=True)) | ||
| return | ||
| if not descriptors: | ||
| typer.echo("No online Hosts found") | ||
| return | ||
|
|
||
| rows: list[tuple[str, ...]] = [] | ||
| for item in descriptors: | ||
| if isinstance(item, dict): | ||
| rows.append((item["host_id"], "-", "-", "unreachable", "-", "-")) | ||
| continue | ||
| rows.append( | ||
| ( | ||
| item.host_id, | ||
| item.name, | ||
| ",".join(sorted(item.tags)) or "-", | ||
| item.state, | ||
| ",".join(item.active_run_ids) or "-", | ||
| str(item.versions.get("dimos", "-")), | ||
| ) | ||
| ) | ||
| typer.echo(_format_table(("ID", "NAME", "TAGS", "STATE", "RUNS", "DIMOS"), rows)) | ||
|
|
||
|
|
||
| @host_app.command() | ||
| def describe( | ||
| host: str = typer.Argument(..., help="Host ID or unique exact name"), | ||
| json_output: bool = typer.Option(False, "--json", help="Output descriptor as JSON"), | ||
| timeout: float = typer.Option( | ||
| DEFAULT_DISCOVERY_TIMEOUT, | ||
| "--timeout", | ||
| min=0.1, | ||
| help="Discovery and RPC timeout in seconds", | ||
| ), | ||
| ) -> None: | ||
| """Describe one online Host by ID or unique exact name.""" | ||
| try: | ||
| with _host_rpc() as rpc: | ||
| host_ids = _discover_host_ids(rpc, timeout) | ||
| if host in host_ids: | ||
| descriptor = _get_descriptor(rpc, host, timeout) | ||
| else: | ||
| matches = [] | ||
| for discovered_id in host_ids: | ||
| item = _get_descriptor(rpc, discovered_id, timeout) | ||
| if item.name == host: | ||
| matches.append(item) | ||
| if not matches: | ||
| raise ValueError(f"No online Host matches {host!r}") | ||
| if len(matches) > 1: | ||
| ids = ", ".join(item.host_id for item in matches) | ||
| raise ValueError(f"Host name {host!r} is ambiguous: {ids}") | ||
| descriptor = matches[0] | ||
| except Exception as exc: | ||
| _fail(str(exc)) | ||
|
|
||
| data = _descriptor_dict(descriptor) | ||
| if json_output: | ||
| typer.echo(json.dumps(data, indent=2, sort_keys=True)) | ||
| return | ||
| typer.echo(f"Host ID: {descriptor.host_id}") | ||
| typer.echo(f"Epoch: {descriptor.epoch}") | ||
| typer.echo(f"Name: {descriptor.name}") | ||
| typer.echo(f"Tags: {','.join(sorted(descriptor.tags)) or '-'}") | ||
| typer.echo(f"State: {descriptor.state}") | ||
| typer.echo(f"Active run IDs: {','.join(descriptor.active_run_ids) or '-'}") | ||
| typer.echo("Versions:") | ||
| for name, value in sorted(descriptor.versions.items()): | ||
| typer.echo(f" {name}: {value}") | ||
|
|
||
|
|
||
| def _zenoh_config_detail() -> str: | ||
| from dimos.protocol.service.zenohservice import ZenohConfig | ||
|
|
||
| config = ZenohConfig(**_zenoh_kwargs()) | ||
| endpoints = ",".join(config.connect) or "none" | ||
| return ( | ||
| f"mode={config.mode}, connect={endpoints}, " | ||
| f"scouting={config.scouting}, multicast={config.multicast}" | ||
| ) | ||
|
|
||
|
|
||
| def _check_zenoh_connection() -> str: | ||
| with _host_rpc() as rpc: | ||
| link_count = len(list(rpc.session.info.links())) | ||
| return f"session opened ({link_count} link(s))" | ||
|
|
||
|
|
||
| @host_app.command() | ||
| def doctor() -> None: | ||
| """Check the local Host identity, Zenoh configuration, connection, and version.""" | ||
| checks: list[tuple[str, Callable[[], str]]] = [ | ||
| ("Host ID", lambda: _load_host_id(HOST_ID_PATH)), | ||
| ("Zenoh config", _zenoh_config_detail), | ||
| ("Zenoh connection", _check_zenoh_connection), | ||
| ("DimOS version", lambda: package_version("dimos")), | ||
| ] | ||
| failures = 0 | ||
| for name, check in checks: | ||
| try: | ||
| detail = check() | ||
| except Exception as exc: | ||
| failures += 1 | ||
| typer.echo(f"FAIL {name}: {exc}", err=True) | ||
| else: | ||
| typer.echo(f"PASS {name}: {detail}") | ||
| if failures: | ||
| typer.echo(f"Host doctor found {failures} problem(s).", err=True) | ||
| raise typer.Exit(1) | ||
| typer.echo("Host doctor passed.") | ||
|
|
||
|
|
||
| @host_app.command() | ||
| def serve( | ||
| name: str | None = typer.Option(None, "--name", help="Human-readable Host name"), | ||
| tags: list[str] = typer.Option([], "--tag", "-t", help="Placement tag; repeatable"), | ||
| ) -> None: | ||
| """Serve one Host over the configured Zenoh fabric.""" | ||
| from dimos.hosted.daemon import ( | ||
| FRAGMENT_SCHEMA_VERSION, | ||
| HOST_CONTROL_RPC_NAME, | ||
| HOST_LIVELINESS_KEY, | ||
| HOST_PROTOCOL_VERSION, | ||
| HostDaemon, | ||
| ) | ||
| from dimos.protocol.rpc.zenohrpc import ZenohRPC | ||
| from dimos.protocol.service.zenohservice import ZenohSessionPool | ||
|
|
||
| try: | ||
| lock_file = _acquire_host_lock(HOST_LOCK_PATH) | ||
| except (OSError, RuntimeError) as exc: | ||
| _fail(str(exc)) | ||
|
|
||
| with lock_file: | ||
| host_id = _load_host_id(HOST_ID_PATH) | ||
| daemon = HostDaemon( | ||
| host_id, | ||
| name=name, | ||
| tags=set(tags), | ||
| versions={ | ||
| "protocol": HOST_PROTOCOL_VERSION, | ||
| "fragment_schema": FRAGMENT_SCHEMA_VERSION, | ||
| "dimos": package_version("dimos"), | ||
| }, | ||
| ) | ||
| pool = ZenohSessionPool() | ||
| rpc = ZenohRPC(session_pool=pool, **_zenoh_kwargs()) | ||
| with ExitStack() as cleanup: | ||
| cleanup.callback(pool.close_all) | ||
| cleanup.callback(daemon.shutdown) | ||
| rpc.start() | ||
| cleanup.callback(rpc.stop) | ||
| control_name = HOST_CONTROL_RPC_NAME.format(host_id=host_id) | ||
| rpc.serve_rpc(daemon.describe, f"{control_name}/describe") # type: ignore[arg-type] | ||
| rpc.serve_rpc(daemon.start, f"{control_name}/start") # type: ignore[arg-type] | ||
| rpc.serve_rpc(daemon.status, f"{control_name}/status") # type: ignore[arg-type] | ||
| rpc.serve_rpc(daemon.stop, f"{control_name}/stop") # type: ignore[arg-type] | ||
| token = rpc.session.liveliness().declare_token( | ||
| HOST_LIVELINESS_KEY.format(host_id=host_id) | ||
| ) | ||
| cleanup.callback(token.undeclare) | ||
| descriptor = daemon.describe() | ||
| typer.echo(f"Host {descriptor.name} ({host_id}) is available") | ||
| try: | ||
| threading.Event().wait() | ||
| except KeyboardInterrupt: | ||
| pass | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If an untrusted peer can reach the shared Zenoh fabric, it can send a request to this Host start endpoint. The request is pickle-deserialized before
HostDaemon.startchecks its epoch or fragment metadata, and there is no caller authentication or authorization boundary. A peer can therefore trigger arbitrary pickle reconstruction and execute code in the Host service.How this was verified: A payload reducer executed before the lifecycle handler returned its stale-epoch validation error.
Knowledge Base Used:
Artifacts
Host RPC validation source
Direct lifecycle validation output
Anonymous Host RPC output