diff --git a/data/.lfs/mermaid.min.js.tar.gz b/data/.lfs/mermaid.min.js.tar.gz new file mode 100644 index 0000000000..27599a64ca --- /dev/null +++ b/data/.lfs/mermaid.min.js.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:016ba263bb37555515227f55ed59f72cfe761110c00ef6c1b8aa0d88b9e6c20c +size 907963 diff --git a/dimos/core/introspection/mermaid.py b/dimos/core/introspection/mermaid.py new file mode 100644 index 0000000000..83f44feeb7 --- /dev/null +++ b/dimos/core/introspection/mermaid.py @@ -0,0 +1,370 @@ +# Copyright 2025-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. + +"""Mermaid flowchart renderer for Blueprint visualization.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from dimos.core.coordination.blueprints import Blueprint +from dimos.core.introspection.utils import sanitize_id +from dimos.core.module import ModuleBase + +Theme = dict[str, Any] + +THEMES: dict[str, Theme] = { + "tailwind": { + "background": "#1e1e1e", + "mermaid_theme": "dark", + "nodes": [ + "#3b82f6", + "#ef4444", + "#22c55e", + "#8b5cf6", + "#f97316", + "#06b6d4", + "#ec4899", + "#6366f1", + "#eab308", + "#14b8a6", + "#f43f5e", + "#84cc16", + "#0ea5e9", + "#d946ef", + "#10b981", + "#a855f7", + "#f59e0b", + "#38bdf8", + "#fb7185", + "#a3e635", + ], + "edges": [ + "#60a5fa", + "#f87171", + "#4ade80", + "#a78bfa", + "#fb923c", + "#22d3ee", + "#f472b6", + "#818cf8", + "#facc15", + "#2dd4bf", + "#fb7185", + "#a3e635", + "#38bdf8", + "#e879f9", + "#34d399", + "#c084fc", + "#fbbf24", + "#67e8f9", + "#fda4af", + "#bef264", + ], + }, + "ocean": { + "background": "#0f172a", + "mermaid_theme": "dark", + "nodes": [ + "#38bdf8", + "#818cf8", + "#2dd4bf", + "#a78bfa", + "#67e8f9", + "#c084fc", + "#5eead4", + "#93c5fd", + "#7dd3fc", + "#6366f1", + ], + "edges": [ + "#7dd3fc", + "#a5b4fc", + "#99f6e4", + "#c4b5fd", + "#a5f3fc", + "#ddd6fe", + "#6ee7b7", + "#bfdbfe", + "#bae6fd", + "#a5b4fc", + ], + }, + "ember": { + "background": "#1c1210", + "mermaid_theme": "dark", + "nodes": [ + "#ef4444", + "#f97316", + "#eab308", + "#f59e0b", + "#fb923c", + "#fbbf24", + "#f87171", + "#facc15", + "#fb7185", + "#fca5a5", + ], + "edges": [ + "#fca5a5", + "#fdba74", + "#fde047", + "#fcd34d", + "#fed7aa", + "#fef08a", + "#fecaca", + "#fef9c3", + "#fda4af", + "#fecdd3", + ], + }, + "forest": { + "background": "#0f1a14", + "mermaid_theme": "dark", + "nodes": [ + "#22c55e", + "#14b8a6", + "#84cc16", + "#10b981", + "#a3e635", + "#34d399", + "#4ade80", + "#2dd4bf", + "#86efac", + "#6ee7b7", + ], + "edges": [ + "#86efac", + "#5eead4", + "#bef264", + "#6ee7b7", + "#d9f99d", + "#99f6e4", + "#bbf7d0", + "#a7f3d0", + "#ecfccb", + "#ccfbf1", + ], + }, + "light": { + "background": "#f8fafc", + "mermaid_theme": "default", + "nodes": [ + "#2563eb", + "#dc2626", + "#16a34a", + "#7c3aed", + "#ea580c", + "#0891b2", + "#db2777", + "#4f46e5", + "#ca8a04", + "#0d9488", + ], + "edges": [ + "#3b82f6", + "#ef4444", + "#22c55e", + "#8b5cf6", + "#f97316", + "#06b6d4", + "#ec4899", + "#6366f1", + "#eab308", + "#14b8a6", + ], + }, +} + +DEFAULT_THEME = "tailwind" + +DEFAULT_IGNORED_CONNECTIONS: set[tuple[str, str]] = set() +DEFAULT_IGNORED_MODULES: set[str] = set() + + +class _ColorAssigner: + def __init__(self, palette: list[str]) -> None: + self._palette = palette + self._assigned: dict[str, str] = {} + self._next = 0 + + def __call__(self, key: str) -> str: + if key not in self._assigned: + self._assigned[key] = self._palette[self._next % len(self._palette)] + self._next += 1 + return self._assigned[key] + + +def render_mermaid( + blueprint_set: Blueprint, + *, + ignored_streams: set[tuple[str, str]] | None = None, + ignored_modules: set[str] | None = None, + show_disconnected: bool = False, + theme: str = DEFAULT_THEME, +) -> tuple[str, dict[str, str], set[str], dict[str, str]]: + """Generate a Mermaid flowchart from a Blueprint. + + Returns (mermaid_code, label_color_map, disconnected_labels, node_color_map). + """ + if ignored_streams is None: + ignored_streams = DEFAULT_IGNORED_CONNECTIONS + if ignored_modules is None: + ignored_modules = DEFAULT_IGNORED_MODULES + + producers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list) + consumers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list) + module_names: set[str] = set() + + for bp in blueprint_set.blueprints: + if bp.module.__name__ in ignored_modules: + continue + module_names.add(bp.module.__name__) + for conn in bp.streams: + remapped_name = blueprint_set.remapping_map.get((bp.module, conn.name), conn.name) + if not isinstance(remapped_name, str): + continue + key = (remapped_name, conn.type) + if conn.direction == "out": + producers[key].append(bp.module) + else: + consumers[key].append(bp.module) + + active_keys: list[tuple[str, type]] = [] + for key in producers: + name, type_ = key + if key not in consumers: + continue + if (name, type_.__name__) in ignored_streams: + continue + valid_producers = [m for m in producers[key] if m.__name__ not in ignored_modules] + valid_consumers = [m for m in consumers[key] if m.__name__ not in ignored_modules] + if valid_producers and valid_consumers: + active_keys.append(key) + + disconnected_keys: list[tuple[str, type]] = [] + if show_disconnected: + all_keys = set(producers.keys()) | set(consumers.keys()) + for key in all_keys: + if key in active_keys: + continue + name, type_ = key + if (name, type_.__name__) in ignored_streams: + continue + relevant = producers.get(key, []) + consumers.get(key, []) + if all(m.__name__ in ignored_modules for m in relevant): + continue + disconnected_keys.append(key) + + palette = THEMES.get(theme, THEMES[DEFAULT_THEME]) + node_color = _ColorAssigner(palette["nodes"]) + edge_color = _ColorAssigner(palette["edges"]) + + lines = ["graph LR"] + + sorted_modules = sorted(module_names) + for module_name in sorted_modules: + mermaid_id = sanitize_id(module_name) + lines.append(f" {mermaid_id}([{module_name}]):::moduleNode") + + lines.append("") + + edge_idx = 0 + edge_colors: list[str] = [] + label_color_map: dict[str, str] = {} + stream_node_ids: dict[str, str] = {} + disconnected_labels: set[str] = set() + + lines.append(" %% Stream nodes and edges") + for key in sorted(active_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"): + name, type_ = key + label = f"{name}:{type_.__name__}" + color = edge_color(label) + label_color_map[label] = color + + valid_producers = [m for m in producers[key] if m.__name__ not in ignored_modules] + valid_consumers = [m for m in consumers[key] if m.__name__ not in ignored_modules] + + for prod in valid_producers: + stream_node_id = sanitize_id(f"{prod.__name__}_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + + producer_id = sanitize_id(prod.__name__) + lines.append(f" {producer_id} --- {stream_node_id}") + edge_colors.append(node_color(prod.__name__)) + edge_idx += 1 + + for cons in valid_consumers: + consumer_id = sanitize_id(cons.__name__) + lines.append(f" {stream_node_id} --> {consumer_id}") + edge_colors.append(color) + edge_idx += 1 + + if disconnected_keys: + lines.append("") + lines.append(" %% Disconnected streams") + for key in sorted(disconnected_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"): + name, type_ = key + label = f"{name}:{type_.__name__}" + color = edge_color(label) + label_color_map[label] = color + disconnected_labels.add(label) + + for prod in producers.get(key, []): + if prod.__name__ in ignored_modules: + continue + stream_node_id = sanitize_id(f"{prod.__name__}_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + producer_id = sanitize_id(prod.__name__) + lines.append(f" {producer_id} -.- {stream_node_id}") + edge_colors.append(node_color(prod.__name__)) + edge_idx += 1 + + for cons in consumers.get(key, []): + if cons.__name__ in ignored_modules: + continue + stream_node_id = sanitize_id(f"dangling_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + consumer_id = sanitize_id(cons.__name__) + lines.append(f" {stream_node_id} -.-> {consumer_id}") + edge_colors.append(color) + edge_idx += 1 + + lines.append("") + for module_name in sorted_modules: + mermaid_id = sanitize_id(module_name) + color = node_color(module_name) + lines.append( + f" style {mermaid_id} fill:{color}bf,stroke:{color},color:#eee,stroke-width:2px" + ) + + for stream_node_id, color in stream_node_ids.items(): + lines.append( + f" style {stream_node_id} fill:transparent,stroke:{color},color:{color},stroke-width:1px" + ) + + if edge_colors: + lines.append("") + for i, color in enumerate(edge_colors): + lines.append(f" linkStyle {i} stroke:{color},stroke-width:2px") + + node_color_map = dict(node_color._assigned) + return "\n".join(lines), label_color_map, disconnected_labels, node_color_map diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 3f94a6be4e..48b9acd235 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -802,6 +802,34 @@ def apriltag( typer.echo(f"Wrote {len(id_list)} tag(s) to {path}") +@main.command() +def graph( + python_file: str = typer.Argument(..., help="Python file containing Blueprint globals"), + no_disconnected: bool = typer.Option( + False, "--no-disconnected", help="Hide disconnected streams" + ), + port: int = typer.Option(0, "--port", help="HTTP server port (0 = random free port)"), + markdown: bool = typer.Option( + False, "--markdown", help="Print Mermaid markdown to stdout and exit" + ), + html: str = typer.Option("", "--html", help="Write HTML to a file instead of serving"), + theme: str = typer.Option( + "tailwind", "--theme", help="Color theme (tailwind, ocean, ember, forest, light)" + ), +) -> None: + """Render DimOS Blueprint graphs. Serves in browser by default, or use --markdown / --html.""" + from dimos.utils.cli.graph import print_markdown, save_html, serve_graph + + show_disconnected = not no_disconnected + if markdown: + print_markdown(python_file, show_disconnected=show_disconnected, theme=theme) + return + if html: + save_html(python_file, output_path=html, show_disconnected=show_disconnected, theme=theme) + return + serve_graph(python_file, show_disconnected=show_disconnected, port=port, theme=theme) + + @main.command(name="rerun-bridge") def rerun_bridge_cmd( memory_limit: str = typer.Option( diff --git a/dimos/utils/cli/graph.html.jinja b/dimos/utils/cli/graph.html.jinja new file mode 100644 index 0000000000..88c9e39dd7 --- /dev/null +++ b/dimos/utils/cli/graph.html.jinja @@ -0,0 +1,331 @@ + + + +Blueprint Diagrams + + + +{% if tab_buttons | length > 1 %} +
+ {% for button in tab_buttons %} + + {% endfor %} +
+{% endif %} +{% for panel in tab_panels %} +
+
+
+{{ panel.mermaid_code }}
+
+
+
+{% endfor %} +
+
+ + + +
+ + + diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py new file mode 100644 index 0000000000..b80340d0cb --- /dev/null +++ b/dimos/utils/cli/graph.py @@ -0,0 +1,292 @@ +# Copyright 2025-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. + +"""Render DimOS Blueprint graphs as Mermaid diagrams in the browser. + +Loads Blueprint instances defined as module-level variables in a Python file +and serves an interactive Mermaid flowchart per blueprint. +""" + +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, HTTPServer +import importlib.util +import json +import os +from pathlib import Path +import sys +from typing import Any +import webbrowser + +import jinja2 + +from dimos.core.coordination.blueprints import Blueprint +from dimos.core.introspection.mermaid import DEFAULT_THEME, THEMES, render_mermaid +from dimos.utils.data import get_data + +_CLI_DIR = Path(__file__).parent +_MERMAID_JS = get_data("mermaid.min.js").read_text(encoding="utf-8") +_TEMPLATE = jinja2.Template( + (_CLI_DIR / "graph.html.jinja").read_text(encoding="utf-8"), + autoescape=False, +) + + +def _levenshtein(a: str, b: str) -> int: + if len(a) < len(b): + return _levenshtein(b, a) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, char_a in enumerate(a): + curr = [i + 1] + for j, char_b in enumerate(b): + curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (char_a != char_b))) + prev = curr + return prev[-1] + + +def _find_package_root(filepath: str) -> str | None: + directory = os.path.dirname(filepath) + root = None + while os.path.isfile(os.path.join(directory, "__init__.py")): + root = directory + parent = os.path.dirname(directory) + if parent == directory: + break + directory = parent + if root is not None: + return os.path.dirname(root) + return None + + +def _load_blueprints(python_file: str) -> list[tuple[str, Blueprint]]: + filepath = os.path.abspath(python_file) + if not os.path.isfile(filepath): + raise FileNotFoundError(filepath) + + pkg_root = _find_package_root(filepath) + if pkg_root and pkg_root not in sys.path: + sys.path.insert(0, pkg_root) + + spec = importlib.util.spec_from_file_location("_render_target", filepath) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load {filepath}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + blueprints: list[tuple[str, Blueprint]] = [] + for name, obj in vars(module).items(): + if isinstance(obj, Blueprint): + blueprints.append((name, obj)) + + if not blueprints: + raise RuntimeError("No Blueprint instances found in module globals.") + + blueprints.reverse() + print( + f"Found {len(blueprints)} blueprint(s): {', '.join(n for n, _ in blueprints)}", + file=sys.stderr, + ) + return blueprints + + +def _build_html( + python_file: str, *, show_disconnected: bool = True, theme: str = DEFAULT_THEME +) -> str: + blueprints = _load_blueprints(python_file) + palette = THEMES.get(theme, THEMES[DEFAULT_THEME]) + background = palette.get("background", "#1e1e1e") + mermaid_theme = palette.get("mermaid_theme", "dark") + is_light = mermaid_theme != "dark" + text_color = "#334155" if is_light else "#ccc" + text_muted = "#64748b" if is_light else "#888" + text_bright = "#1e293b" if is_light else "#eee" + surface = "#e2e8f0" if is_light else "#252525" + surface_hover = "#cbd5e1" if is_light else "#2a2a2a" + controls_bg = "#e2e8f0" if is_light else "#2a2a2a" + controls_btn = "#cbd5e1" if is_light else "#333" + controls_border = "#94a3b8" if is_light else "#555" + border_color = "#cbd5e1" if is_light else "#444" + label_bg = "rgba(248,250,252,0.85)" if is_light else "rgba(30,30,30,0.7)" + + per_bp_label_colors: list[dict[str, str]] = [] + per_bp_disconnected: list[set[str]] = [] + per_bp_node_colors: list[dict[str, str]] = [] + per_bp_conflicts: list[list[dict[str, Any]]] = [] + per_bp_typos: list[list[dict[str, object]]] = [] + + tab_buttons: list[dict[str, str]] = [] + tab_panels: list[dict[str, str]] = [] + for name, bp in blueprints: + mermaid_code, label_colors, disconnected, node_colors = render_mermaid( + bp, show_disconnected=show_disconnected, theme=theme + ) + per_bp_label_colors.append(label_colors) + per_bp_disconnected.append(disconnected) + per_bp_node_colors.append(node_colors) + + producers: dict[str, list[str]] = {} + for atom in bp.blueprints: + for stream in atom.streams: + if stream.direction == "out": + topic = f"{stream.name}:{stream.type.__name__}" + producers.setdefault(topic, []).append(atom.module.__name__) + conflicts: list[dict[str, object]] = [ + { + "topic": topic, + "topicColor": label_colors.get(topic, "#ccc"), + "modules": [ + {"name": module_name, "color": node_colors.get(module_name, "#ccc")} + for module_name in modules + ], + } + for topic, modules in producers.items() + if len(modules) > 1 + ] + per_bp_conflicts.append(conflicts) + + outputs: dict[tuple[str, str], list[str]] = {} + inputs: dict[tuple[str, str], list[str]] = {} + for atom in bp.blueprints: + for stream in atom.streams: + key = (stream.name, stream.type.__name__) + if stream.direction == "out": + outputs.setdefault(key, []).append(atom.module.__name__) + else: + inputs.setdefault(key, []).append(atom.module.__name__) + dangling_outs = {k: v for k, v in outputs.items() if k not in inputs} + dangling_ins = {k: v for k, v in inputs.items() if k not in outputs} + typos: list[dict[str, object]] = [] + for (out_name, out_type), out_modules in dangling_outs.items(): + for (in_name, in_type), in_modules in dangling_ins.items(): + if out_type != in_type: + continue + distance = _levenshtein(out_name, in_name) + if 0 < distance <= 2: + out_label = f"{out_name}:{out_type}" + in_label = f"{in_name}:{in_type}" + typos.append( + { + "outLabel": out_label, + "inLabel": in_label, + "outColor": label_colors.get(out_label, "#ccc"), + "inColor": label_colors.get(in_label, "#ccc"), + "outModules": [ + {"name": m, "color": node_colors.get(m, "#ccc")} + for m in out_modules + ], + "inModules": [ + {"name": m, "color": node_colors.get(m, "#ccc")} for m in in_modules + ], + } + ) + per_bp_typos.append(typos) + + tab_buttons.append({"name": name}) + tab_panels.append({"mermaid_code": mermaid_code}) + + return _TEMPLATE.render( + background=background, + text_color=text_color, + text_muted=text_muted, + text_bright=text_bright, + surface=surface, + surface_hover=surface_hover, + controls_bg=controls_bg, + controls_btn=controls_btn, + controls_border=controls_border, + border_color=border_color, + label_bg=label_bg, + mermaid_theme=mermaid_theme, + mermaid_js=_MERMAID_JS, + tab_buttons=tab_buttons, + tab_panels=tab_panels, + all_label_colors_json=json.dumps(per_bp_label_colors), + all_disconnected_json=json.dumps([sorted(d) for d in per_bp_disconnected]), + all_conflicts_json=json.dumps(per_bp_conflicts), + all_typos_json=json.dumps(per_bp_typos), + ) + + +def print_markdown( + python_file: str, *, show_disconnected: bool, theme: str = DEFAULT_THEME +) -> None: + blueprints = _load_blueprints(python_file) + sections: list[str] = [] + for name, bp in blueprints: + mermaid_code, _, _, _ = render_mermaid(bp, show_disconnected=show_disconnected, theme=theme) + sections.append(f"## {name}\n\n```mermaid\n{mermaid_code}\n```") + print("\n\n".join(sections)) + + +def save_html( + python_file: str, + *, + output_path: str, + show_disconnected: bool, + theme: str = DEFAULT_THEME, +) -> None: + html = _build_html(python_file, show_disconnected=show_disconnected, theme=theme) + with open(output_path, "w") as file: + file.write(html) + print(f"Wrote {output_path}", file=sys.stderr) + + +def serve_graph( + python_file: str, *, show_disconnected: bool, port: int, theme: str = DEFAULT_THEME +) -> None: + html = _build_html(python_file, show_disconnected=show_disconnected, theme=theme) + html_bytes = html.encode("utf-8") + + favicon_svg = ( + b'' + b'' + b'' + b'' + b'' + b'' + b"" + ) + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path == "/favicon.ico": + self.send_response(200) + self.send_header("Content-Type", "image/svg+xml") + self.send_header("Content-Length", str(len(favicon_svg))) + self.end_headers() + self.wfile.write(favicon_svg) + return + if self.path not in ("/", ""): + self.send_response(204) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html_bytes))) + self.end_headers() + self.wfile.write(html_bytes) + + def log_message(self, format: str, *args: object) -> None: + pass + + server = HTTPServer(("127.0.0.1", port), Handler) + actual_port = server.server_address[1] + url = f"http://localhost:{actual_port}" + print(f"Serving at {url} (will exit after first request)") + webbrowser.open(url) + server.handle_request() + print("Served. Exiting.") diff --git a/dimos/utils/cli/test_graph.py b/dimos/utils/cli/test_graph.py new file mode 100644 index 0000000000..fb8519cc5f --- /dev/null +++ b/dimos/utils/cli/test_graph.py @@ -0,0 +1,164 @@ +# Copyright 2025-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. + +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +import re +import threading + +import pytest +import requests + +from dimos.core.coordination.blueprints import Blueprint, autoconnect +from dimos.core.module import Module +from dimos.core.stream import In, Out + +SNAPSHOT_PATH = Path(__file__).with_name("test_graph_snapshot.html") + + +class ImageData: + pass + + +class DepthData: + pass + + +class OdometryData: + pass + + +class PlanData: + pass + + +class CmdVelData: + pass + + +class PointCloudData: + pass + + +class CameraModule(Module): + color_image: Out[ImageData] + depth_image: Out[DepthData] + point_cloud: Out[PointCloudData] + + +class OdometryModule(Module): + odometry: Out[OdometryData] + + +class PerceptionModule(Module): + color_image: In[ImageData] + depth_image: In[DepthData] + odometry: In[OdometryData] + + +class PlannerModule(Module): + odometry: In[OdometryData] + plan: Out[PlanData] + + +class ControllerModule(Module): + plan: In[PlanData] + odometry: In[OdometryData] + cmd_vel: Out[CmdVelData] + + +class VisualizerModule(Module): + color_image: In[ImageData] + point_cloud: In[PointCloudData] + + +def _build_blueprint() -> Blueprint: + return autoconnect( + CameraModule.blueprint(), + OdometryModule.blueprint(), + PerceptionModule.blueprint(), + PlannerModule.blueprint(), + ControllerModule.blueprint(), + VisualizerModule.blueprint(), + ) + + +def _normalize_html(html: str) -> str: + html = re.sub( + r"", + "", + html, + count=1, + flags=re.DOTALL, + ) + return "\n".join(line.rstrip() for line in html.splitlines()) + + +def _serve_and_fetch(html: str) -> str: + html_bytes = html.encode() + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html_bytes))) + self.end_headers() + self.wfile.write(html_bytes) + + def log_message(self, format: str, *args: object) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + port = server.server_address[1] + + thread = threading.Thread(target=server.handle_request, daemon=True) + thread.start() + + response = requests.get(f"http://127.0.0.1:{port}/", timeout=10) + body = response.text + + thread.join(timeout=5) + server.server_close() + return body + + +def test_graph_server_snapshot() -> None: + blueprint_file = str(Path(__file__).with_name("test_graph_blueprints.py")) + + from dimos.utils.cli.graph import _build_html + + html = _build_html(blueprint_file, show_disconnected=True) + + html_normalized = _normalize_html(html) + + assert "" in html_normalized + assert "CameraModule" in html_normalized + assert "mermaid" in html_normalized + + served_html = _serve_and_fetch(html) + served_normalized = _normalize_html(served_html) + assert served_normalized == html_normalized + + if SNAPSHOT_PATH.exists(): + snapshot = SNAPSHOT_PATH.read_text().rstrip("\n") + if snapshot != html_normalized.rstrip("\n"): + SNAPSHOT_PATH.write_text(html_normalized.rstrip("\n") + "\n") + pytest.fail( + f"Snapshot mismatch — updated {SNAPSHOT_PATH.name}. " + "Re-run to confirm the new snapshot passes." + ) + else: + SNAPSHOT_PATH.write_text(html_normalized.rstrip("\n") + "\n") diff --git a/dimos/utils/cli/test_graph_blueprints.py b/dimos/utils/cli/test_graph_blueprints.py new file mode 100644 index 0000000000..cdbf7ef99b --- /dev/null +++ b/dimos/utils/cli/test_graph_blueprints.py @@ -0,0 +1,119 @@ +# Copyright 2025-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. + +from __future__ import annotations + +from pathlib import Path + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.module import Module +from dimos.core.stream import In, Out + +SNAPSHOT_PATH = Path(__file__).with_name("test_graph_snapshot.html") + + +class ImageData: + pass + + +class DepthData: + pass + + +class OdometryData: + pass + + +class PlanData: + pass + + +class CmdVelData: + pass + + +class PointCloudData: + pass + + +class CameraModule(Module): + # intentionally doesn't match "color_image" + color_img: Out[ImageData] + depth_image: Out[DepthData] + point_cloud: Out[PointCloudData] + + +class OdometryModule(Module): + odometry: Out[OdometryData] + + +class PerceptionModule(Module): + color_image: In[ImageData] + depth_image: In[DepthData] + odometry: In[OdometryData] + + +class PlannerModule(Module): + odometry: In[OdometryData] + plan: Out[PlanData] + + +class PlannerModule2(Module): + odometry: In[OdometryData] + plan: Out[PlanData] + + +class ControllerModule(Module): + plan: In[PlanData] + odometry: In[OdometryData] + cmd_vel: Out[CmdVelData] + + +class ControllerModule2(Module): + plan: In[PlanData] + odometry: In[OdometryData] + cmd_vel: Out[CmdVelData] + + +class VisualizerModule(Module): + color_image: In[ImageData] + point_cloud: In[PointCloudData] + + +_blueprint1 = autoconnect( + CameraModule.blueprint(), + OdometryModule.blueprint(), + PerceptionModule.blueprint(), + PlannerModule.blueprint(), + ControllerModule.blueprint(), + VisualizerModule.blueprint(), +) + +_blueprint2 = autoconnect( + CameraModule.blueprint(), + PlannerModule.blueprint(), + ControllerModule.blueprint(), + VisualizerModule.blueprint(), +) + +_blueprint3 = autoconnect( + CameraModule.blueprint(), + OdometryModule.blueprint(), + PerceptionModule.blueprint(), + PlannerModule.blueprint(), + PlannerModule2.blueprint(), # intenional double + ControllerModule.blueprint(), + ControllerModule2.blueprint(), + VisualizerModule.blueprint(), +) diff --git a/dimos/utils/cli/test_graph_snapshot.html b/dimos/utils/cli/test_graph_snapshot.html new file mode 100644 index 0000000000..af90b1795f --- /dev/null +++ b/dimos/utils/cli/test_graph_snapshot.html @@ -0,0 +1,541 @@ + + + +Blueprint Diagrams + + + + +
+ + + + + + + +
+ + +
+
+
+graph LR
+    CameraModule([CameraModule]):::moduleNode
+    ControllerModule([ControllerModule]):::moduleNode
+    ControllerModule2([ControllerModule2]):::moduleNode
+    OdometryModule([OdometryModule]):::moduleNode
+    PerceptionModule([PerceptionModule]):::moduleNode
+    PlannerModule([PlannerModule]):::moduleNode
+    PlannerModule2([PlannerModule2]):::moduleNode
+    VisualizerModule([VisualizerModule]):::moduleNode
+
+    %% Stream nodes and edges
+    CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+    CameraModule --- CameraModule_depth_image_DepthData
+    CameraModule_depth_image_DepthData --> PerceptionModule
+    OdometryModule_odometry_OdometryData[odometry:OdometryData]:::streamNode
+    OdometryModule --- OdometryModule_odometry_OdometryData
+    OdometryModule_odometry_OdometryData --> PerceptionModule
+    OdometryModule_odometry_OdometryData --> PlannerModule
+    OdometryModule_odometry_OdometryData --> PlannerModule2
+    OdometryModule_odometry_OdometryData --> ControllerModule
+    OdometryModule_odometry_OdometryData --> ControllerModule2
+    PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+    PlannerModule --- PlannerModule_plan_PlanData
+    PlannerModule_plan_PlanData --> ControllerModule
+    PlannerModule_plan_PlanData --> ControllerModule2
+    PlannerModule2_plan_PlanData[plan:PlanData]:::streamNode
+    PlannerModule2 --- PlannerModule2_plan_PlanData
+    PlannerModule2_plan_PlanData --> ControllerModule
+    PlannerModule2_plan_PlanData --> ControllerModule2
+    CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+    CameraModule --- CameraModule_point_cloud_PointCloudData
+    CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+    %% Disconnected streams
+    ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+    ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+    ControllerModule2_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+    ControllerModule2 -.- ControllerModule2_cmd_vel_CmdVelData
+    dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+    dangling_color_image_ImageData -.-> PerceptionModule
+    dangling_color_image_ImageData -.-> VisualizerModule
+    CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+    CameraModule -.- CameraModule_color_img_ImageData
+
+    style CameraModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+    style ControllerModule fill:#f97316bf,stroke:#f97316,color:#eee,stroke-width:2px
+    style ControllerModule2 fill:#06b6d4bf,stroke:#06b6d4,color:#eee,stroke-width:2px
+    style OdometryModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+    style PerceptionModule fill:#ec4899bf,stroke:#ec4899,color:#eee,stroke-width:2px
+    style PlannerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+    style PlannerModule2 fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+    style VisualizerModule fill:#6366f1bf,stroke:#6366f1,color:#eee,stroke-width:2px
+    style CameraModule_depth_image_DepthData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+    style OdometryModule_odometry_OdometryData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+    style PlannerModule_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style PlannerModule2_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+    style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style ControllerModule2_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style dangling_color_image_ImageData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+    style CameraModule_color_img_ImageData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
+
+    linkStyle 0 stroke:#3b82f6,stroke-width:2px
+    linkStyle 1 stroke:#60a5fa,stroke-width:2px
+    linkStyle 2 stroke:#ef4444,stroke-width:2px
+    linkStyle 3 stroke:#f87171,stroke-width:2px
+    linkStyle 4 stroke:#f87171,stroke-width:2px
+    linkStyle 5 stroke:#f87171,stroke-width:2px
+    linkStyle 6 stroke:#f87171,stroke-width:2px
+    linkStyle 7 stroke:#f87171,stroke-width:2px
+    linkStyle 8 stroke:#22c55e,stroke-width:2px
+    linkStyle 9 stroke:#4ade80,stroke-width:2px
+    linkStyle 10 stroke:#4ade80,stroke-width:2px
+    linkStyle 11 stroke:#8b5cf6,stroke-width:2px
+    linkStyle 12 stroke:#4ade80,stroke-width:2px
+    linkStyle 13 stroke:#4ade80,stroke-width:2px
+    linkStyle 14 stroke:#3b82f6,stroke-width:2px
+    linkStyle 15 stroke:#a78bfa,stroke-width:2px
+    linkStyle 16 stroke:#f97316,stroke-width:2px
+    linkStyle 17 stroke:#06b6d4,stroke-width:2px
+    linkStyle 18 stroke:#22d3ee,stroke-width:2px
+    linkStyle 19 stroke:#22d3ee,stroke-width:2px
+    linkStyle 20 stroke:#3b82f6,stroke-width:2px
+
+
+
+ +
+
+
+graph LR
+    CameraModule([CameraModule]):::moduleNode
+    ControllerModule([ControllerModule]):::moduleNode
+    PlannerModule([PlannerModule]):::moduleNode
+    VisualizerModule([VisualizerModule]):::moduleNode
+
+    %% Stream nodes and edges
+    PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+    PlannerModule --- PlannerModule_plan_PlanData
+    PlannerModule_plan_PlanData --> ControllerModule
+    CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+    CameraModule --- CameraModule_point_cloud_PointCloudData
+    CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+    %% Disconnected streams
+    ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+    ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+    dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+    dangling_color_image_ImageData -.-> VisualizerModule
+    CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+    CameraModule -.- CameraModule_color_img_ImageData
+    CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+    CameraModule -.- CameraModule_depth_image_DepthData
+    dangling_odometry_OdometryData[odometry:OdometryData]:::streamNode
+    dangling_odometry_OdometryData -.-> PlannerModule
+    dangling_odometry_OdometryData -.-> ControllerModule
+
+    style CameraModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+    style ControllerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+    style PlannerModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+    style VisualizerModule fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+    style PlannerModule_plan_PlanData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+    style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+    style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style dangling_color_image_ImageData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+    style CameraModule_color_img_ImageData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style CameraModule_depth_image_DepthData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+    style dangling_odometry_OdometryData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
+
+    linkStyle 0 stroke:#3b82f6,stroke-width:2px
+    linkStyle 1 stroke:#60a5fa,stroke-width:2px
+    linkStyle 2 stroke:#ef4444,stroke-width:2px
+    linkStyle 3 stroke:#f87171,stroke-width:2px
+    linkStyle 4 stroke:#22c55e,stroke-width:2px
+    linkStyle 5 stroke:#a78bfa,stroke-width:2px
+    linkStyle 6 stroke:#ef4444,stroke-width:2px
+    linkStyle 7 stroke:#ef4444,stroke-width:2px
+    linkStyle 8 stroke:#f472b6,stroke-width:2px
+    linkStyle 9 stroke:#f472b6,stroke-width:2px
+
+
+
+ +
+
+
+graph LR
+    CameraModule([CameraModule]):::moduleNode
+    ControllerModule([ControllerModule]):::moduleNode
+    OdometryModule([OdometryModule]):::moduleNode
+    PerceptionModule([PerceptionModule]):::moduleNode
+    PlannerModule([PlannerModule]):::moduleNode
+    VisualizerModule([VisualizerModule]):::moduleNode
+
+    %% Stream nodes and edges
+    CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+    CameraModule --- CameraModule_depth_image_DepthData
+    CameraModule_depth_image_DepthData --> PerceptionModule
+    OdometryModule_odometry_OdometryData[odometry:OdometryData]:::streamNode
+    OdometryModule --- OdometryModule_odometry_OdometryData
+    OdometryModule_odometry_OdometryData --> PerceptionModule
+    OdometryModule_odometry_OdometryData --> PlannerModule
+    OdometryModule_odometry_OdometryData --> ControllerModule
+    PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+    PlannerModule --- PlannerModule_plan_PlanData
+    PlannerModule_plan_PlanData --> ControllerModule
+    CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+    CameraModule --- CameraModule_point_cloud_PointCloudData
+    CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+    %% Disconnected streams
+    ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+    ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+    dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+    dangling_color_image_ImageData -.-> PerceptionModule
+    dangling_color_image_ImageData -.-> VisualizerModule
+    CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+    CameraModule -.- CameraModule_color_img_ImageData
+
+    style CameraModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+    style ControllerModule fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+    style OdometryModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+    style PerceptionModule fill:#f97316bf,stroke:#f97316,color:#eee,stroke-width:2px
+    style PlannerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+    style VisualizerModule fill:#06b6d4bf,stroke:#06b6d4,color:#eee,stroke-width:2px
+    style CameraModule_depth_image_DepthData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+    style OdometryModule_odometry_OdometryData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+    style PlannerModule_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+    style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style dangling_color_image_ImageData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+    style CameraModule_color_img_ImageData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
+
+    linkStyle 0 stroke:#3b82f6,stroke-width:2px
+    linkStyle 1 stroke:#60a5fa,stroke-width:2px
+    linkStyle 2 stroke:#ef4444,stroke-width:2px
+    linkStyle 3 stroke:#f87171,stroke-width:2px
+    linkStyle 4 stroke:#f87171,stroke-width:2px
+    linkStyle 5 stroke:#f87171,stroke-width:2px
+    linkStyle 6 stroke:#22c55e,stroke-width:2px
+    linkStyle 7 stroke:#4ade80,stroke-width:2px
+    linkStyle 8 stroke:#3b82f6,stroke-width:2px
+    linkStyle 9 stroke:#a78bfa,stroke-width:2px
+    linkStyle 10 stroke:#8b5cf6,stroke-width:2px
+    linkStyle 11 stroke:#22d3ee,stroke-width:2px
+    linkStyle 12 stroke:#22d3ee,stroke-width:2px
+    linkStyle 13 stroke:#3b82f6,stroke-width:2px
+
+
+
+ +
+
+ + + +
+ + +