From 4d7a632d4e420a61330dc6f604cdba91a1d84228 Mon Sep 17 00:00:00 2001 From: yaniswav Date: Tue, 19 May 2026 19:05:54 +0200 Subject: [PATCH] v0.5.0 polish --- .github/ISSUE_TEMPLATE/bug_report.yml | 120 +++++++++ .github/ISSUE_TEMPLATE/config.yml | 11 + .github/ISSUE_TEMPLATE/feature_request.yml | 78 ++++++ .github/PULL_REQUEST_TEMPLATE.md | 36 +++ .github/workflows/ci.yml | 5 +- CHANGELOG.md | 155 +++++++++++ CONTRIBUTING.md | 110 ++++++++ README.md | 42 +-- SECURITY.md | 80 ++++++ docs/DDS_QUICKSTART.md | 51 ++-- docs/MIGRATION_v0.3_to_v0.4.md | 242 +++++++++++++++++ docs/TESTING.md | 15 +- docs/TROUBLESHOOTING.md | 245 ++++++++++++++++++ .../audit-followup-triage-v0.2.0.md | 54 ++-- examples/01-discover-ros2-stack.md | 73 ++++++ examples/02-debug-qos-mismatch.md | 85 ++++++ examples/03-analyze-recording.md | 81 ++++++ examples/04-monitor-topic-frequency.md | 88 +++++++ examples/README.md | 30 +++ pyproject.toml | 1 + src/topicforge/adapters/common/dds_helpers.py | 20 +- .../adapters/dds_cyclone/adapter.py | 16 +- src/topicforge/adapters/dds_fast/adapter.py | 11 +- src/topicforge/services/bag_service.py | 13 +- src/topicforge/services/inspector.py | 11 +- tests/test_bag_service.py | 41 +++ tests/test_dds_helpers.py | 22 ++ 27 files changed, 1652 insertions(+), 84 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/MIGRATION_v0.3_to_v0.4.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 examples/01-discover-ros2-stack.md create mode 100644 examples/02-debug-qos-mismatch.md create mode 100644 examples/03-analyze-recording.md create mode 100644 examples/04-monitor-topic-frequency.md create mode 100644 examples/README.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9a66088 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,120 @@ +name: Bug report +description: Something broke. Pure-Python reproductions land fastest. +title: "[bug] " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Two minutes of structured detail saves a 30-minute back-and-forth. + + Security vulnerabilities → please use the email in `SECURITY.md`, not this form. + + - type: input + id: version + attributes: + label: TopicForge version + description: "Output of `python -m topicforge --version` or `pip show topicforge`." + placeholder: "topicforge 0.4.0" + validations: + required: true + + - type: dropdown + id: mode + attributes: + label: Runtime mode + description: "From your `TOPICFORGE_MODE` env var or `health_check` output." + options: + - mock + - live + - auto (resolved to live) + - auto (resolved to mock) + - "I don't know" + validations: + required: true + + - type: dropdown + id: dds-backend + attributes: + label: DDS backend (if relevant) + description: "Skip if the bug doesn't touch the DDS module." + options: + - "n/a — bug is on the ROS2 side" + - mock + - cyclone + - fast + - rti + - opendds + - dust + - other / Pro tier vendor + validations: + required: false + + - type: dropdown + id: os + attributes: + label: Operating system + options: + - Windows (native) + - Windows (WSL2) + - Ubuntu / Debian + - macOS + - other Linux + - Docker container + validations: + required: true + + - type: input + id: python + attributes: + label: Python version + placeholder: "3.12.3" + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened + description: Be precise. Quote the exact tool call and the exact response or exception text. Substring of the AdapterError message is gold. + placeholder: | + I called `peek_dds_samples(topic="/foo/bar", count=5)` and got + `AdapterError("CycloneDDS sample peek failed on topic ...")`. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What you expected + placeholder: A SampleResult with up to 5 decoded samples. + validations: + required: true + + - type: textarea + id: reproducer + attributes: + label: Minimal reproducer + description: | + A failing pytest is gold. A copy-pastable shell session is silver. + "It happens sometimes" is bronze — please include the env vars and command line at minimum. + render: shell + validations: + required: false + + - type: textarea + id: env + attributes: + label: Environment variables set + description: "Only the `TOPICFORGE_*` and `ROS_*` / `CYCLONEDDS_*` / `FastDDS_*` ones. Redact anything sensitive." + render: shell + validations: + required: false + + - type: checkboxes + id: checks + attributes: + label: Quick checks + options: + - label: I've read [docs/TROUBLESHOOTING.md](../blob/main/docs/TROUBLESHOOTING.md) and my error isn't covered there. + - label: This is not a security vulnerability (those go to the email in `SECURITY.md`). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4e23b46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/yaniswav/TopicForge/blob/main/SECURITY.md + about: Vulnerability disclosures go to the email in SECURITY.md, not public issues. + - name: Troubleshooting + url: https://github.com/yaniswav/TopicForge/blob/main/docs/TROUBLESHOOTING.md + about: Common AdapterError messages and their remediation paths. + - name: DDS quickstart + url: https://github.com/yaniswav/TopicForge/blob/main/docs/DDS_QUICKSTART.md + about: 5-minute walkthrough for the DDS module — backend selection, QoS mismatch scenario. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..9647e6b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,78 @@ +name: Feature request +description: Propose a new capability. Read the scope notes first. +title: "[feature] " +labels: ["enhancement", "triage"] +body: + - type: markdown + attributes: + value: | + Before opening : TopicForge is intentionally small. The tool surface is capped at 11 + as of v0.4.0 ; each new tool needs an explicit scope discussion (see `docs/product-plan.md §11` + "Scope creep within the TopicForge umbrella"). Many ideas land better in the Pro tier + roadmap (URDF inspection, bag anomaly detection) than as OSS additions. + + For parser fixes on a new ROS2 distro, please file a bug instead with a failing test. + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Concrete, user-facing. Avoid solution language at this stage. + placeholder: | + "When I bring up a new robot stack I cannot tell from `list_topics` alone + which nodes own which topics — I have to cross-reference `ros2 node list` + manually." + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed shape + description: | + Tool name, parameters, return shape if you have a concrete idea. + Drop a Pydantic-like sketch if it helps — the maintainer will refine. + placeholder: | + `list_nodes(name_filter: str | None = None) -> list[NodeInfo]` + where `NodeInfo` carries `name`, `namespace`, `publishers`, `subscribers`, `services`. + validations: + required: false + + - type: dropdown + id: tier + attributes: + label: Which tier should this live in? + options: + - "OSS (core)" + - "Pro tier (URDF / bag anomaly / multi-bag diff family)" + - "I don't know — maintainer decides" + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What did you try before opening this? `ros2` CLI commands, scripts, other MCPs? + placeholder: | + I currently run `ros2 node list && ros2 node info ` then paste the + output into Claude manually. + validations: + required: false + + - type: textarea + id: who-benefits + attributes: + label: Who else benefits? + description: Solo dev? Small team? Specific industry? Helps with scope decisions. + validations: + required: false + + - type: checkboxes + id: checks + attributes: + label: Quick checks + options: + - label: I've checked `docs/product-plan.md` and this is not already on a roadmap. + - label: I've checked existing issues for duplicates. + - label: I understand TopicForge is read-only by architecture and will not propose write/command/publish capabilities. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..459a894 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ + + +## Summary + + + +## Test plan + + + +## Backward-compatibility checklist + + + +- [ ] No new MCP tool added — or, if added : see `docs/product-plan.md §11` (the 11-tool cap requires a scope discussion) +- [ ] No change to Pydantic schemas — or, if changed : every new field has a safe default (`extra="forbid"` + additive optional only) +- [ ] No change to the telemetry 6-field contract pinned by `tests/test_telemetry.py::test_payload_contains_only_whitelisted_keys` +- [ ] No new environment variable name — or, if added : documented in README "Configuration reference" and `.env.example` +- [ ] No removal of an existing public API symbol — or, if removed : CHANGELOG `### Removed` line in `[Unreleased]` + +## Notes for the maintainer + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1403b0e..eab85e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,13 @@ on: jobs: check: name: Lint + format + tests - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12"] + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e98aba..e0e5999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,161 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ ## [Unreleased] +### Sprint v0.5.0 — Polish + validation (pre-marketing-publication) + +> Branch `feat/v0.5.0-polish-and-validation`. Three sub-milestones +> (5.1 real validation, 5.2 audit closure + error polish, 5.3 docs + +> examples + repo polish). No new MCP tool, no schema change. Last +> sprint before marketing publication ; the next version bump is +> the maintainer's manual `release` commit + tag. + +#### Changed (sub-milestone 5.1 — Real validation) + +- **CI matrix expanded** (`.github/workflows/ci.yml`) from + `ubuntu-latest × {3.11, 3.12}` to `{ubuntu-latest, windows-latest} × + {3.11, 3.12, 3.13}` (6 cells, `fail-fast: false`). Windows-latest + coverage closes the largest unverified surface — TopicForge's primary + dev environment is Windows per `CLAUDE.md §7` and was previously only + hand-validated. Python 3.13 added now that wheels are stable across + the dependency footprint. +- **`pyproject.toml` classifiers** widened with `Programming Language :: + Python :: 3.13`. + +#### Changed (sub-milestone 5.2 — AdapterError polish) + +- **`DDS_ONLY_ERROR_MSG`** (`adapters/common/dds_helpers.py`) rewritten + with the v0.4.0 `CompositeAdapter` remediation path explicit, and + with every affected ROS2 tool name listed inline. Substring + `"DDS observability only"`, `"TOPICFORGE_DDS_BACKEND"`, + `"TOPICFORGE_MODE"` preserved — existing `pytest.raises(match=...)` + contracts honored. New token assertions added : + `"CompositeAdapter"`, the 4 affected tool names. +- **CycloneDDS adapter errors** (`adapters/dds_cyclone/adapter.py`) + enriched with the underlying exception type, the active domain id, + the topic name (where relevant), and common-cause diagnostic hints + (`CYCLONEDDS_URI` misconfiguration, multicast firewall, domain + mismatch). Three sites : participant discovery, endpoint discovery, + sample peek. +- **Fast DDS participant-init error** (`adapters/dds_fast/adapter.py`) + enriched with an ABI-mismatch diagnostic — Fast DDS 2.6.x Python + binding wheels frequently desynchronize with system-installed Fast + DDS native libraries, and the v0.4.0 wording masked the cause behind + a bare `"returned None"`. New message points at the pyproject pin + (`fastdds>=2.6.1,<3`) and the `FastDDS_DEFAULT_PROFILES_FILE` env + var as the two likely culprits. +- **`BagService` IO errors** (`services/bag_service.py`) wrap the + rosbags-side exception class name into the AdapterError message so + the LLM caller can pivot on `PermissionError` / `IsADirectoryError` + / etc. without inspecting `__cause__`. +- **`_ROSBAGS_REQUIRED_MSG`** rewording — clarifies that `analyze_bag` + has a v0.3.0 text-parse fallback while `peek_bag_samples` does not. + +#### Closed (sub-milestone 5.2 — audit triage) + +- **`docs/projet-file/audit-followup-triage-v0.2.0.md` refreshed** + against the current tree (was last touched pre-v0.3.0). Strict + B-class : 3 items now CLOSED (B6 in v0.2.0, B9 in v0.3.0, B10 in + v0.5.0). 6 items remain DEFER (B1-B5 hosted-context security + hardening ; B7, B8 wire-contract decisions for v0.6+). +- **`TODO(roadmap, audit-2026-05-14)` at `services/inspector.py:76` + retired as WONT-FIX-by-design** — the `list_topics` Inspector gate + is intentionally empty (no MCP-level args to validate). The comment + block is now a permanent design note rather than a roadmap pointer. + +#### Added (sub-milestone 5.2 — regression tests) + +- **5 new tests** in `tests/test_dds_helpers.py` and + `tests/test_bag_service.py` (regex-token assertions, not exact + wording) — pin the polished message contract without locking the + exact prose. Baseline grows from 394 to 399 passed ; 24 skipped + unchanged ; ruff clean. + +#### Changed (sub-milestone 5.3 — Documentation cascade) + +- **`README.md`** — CI badge added ; tagline refreshed from "v0.3.0" + to "v0.4.0" framing emphasising observability + bag analysis ; the + 3-row DDS tool mini-table grew into a 6-row table listing every + DDS / observability tool with its sub-milestone of origin ; + `peek_dds_samples` scope rewritten around `_decode_status` (full / + partial / raw) ; telemetry contract field description switched from + "five MVP tools" to "eleven MCP tools" ; `TOPICFORGE_DDS_BACKEND` + Literal values listed in the config reference ; Roadmap section + pruned of items that shipped in v0.4.0 (Composite adapter, XTypes + Cyclone push). +- **`docs/DDS_QUICKSTART.md`** — header bumped to v0.4.0+ ; §4 + "Single-adapter limitation (v0.3.0)" replaced by "Composite adapter + (v0.4.0 Phase 1+)" with the new routing table ; §5 documents the + v0.4.0 Phase 1.5 best-effort XTypes story (no more + `AdapterError` on user topics) ; §6 "What's next" pruned of shipped + items ; §7 Troubleshooting updated. +- **`docs/TESTING.md`** — "five MCP tools" → "eleven MCP tools" in the + three documented occurrences ("Pick your path" table row, the lead + paragraph, Path 1 header). New v0.4.0 tool-surface callout above + the path picker. + +#### Added (sub-milestone 5.3 — new docs and examples) + +- **`docs/MIGRATION_v0.3_to_v0.4.md`** (new, sibling to the existing + `MIGRATION_v0.2_to_v0.3.md`). 8 sections : new tools, env vars and + extras, soft-breaking schema widening (`ParticipantInfo` +4 fields, + `BagAnalysis` +4 fields, `HealthReport.ros_backend`, `dds_backend` + widening, `AdapterName` widening, new `TopicMetrics` / + `ParticipantEvent` schemas), `CompositeAdapter`, `peek_dds_samples` + user-topic story, protocol expansions, plus a quick checklist. +- **`docs/TROUBLESHOOTING.md`** (new). One section per polished + AdapterError message, plus the cross-cutting "ROS2 CLI not found on + PATH" / `auto` fallback to mock case. Each section quotes the + message and lists the diagnostics in order. +- **`examples/`** (new). 4 mock-mode-runnable walkthroughs covering + the headline value props : + - `01-discover-ros2-stack.md` — `health_check` + `list_topics` + + `get_topic_info` + `sample_messages` + - `02-debug-qos-mismatch.md` — `list_participants` + + `detect_qos_mismatches` + `peek_dds_samples` (canonical + Reliability mismatch story) + - `03-analyze-recording.md` — `analyze_bag` + `peek_bag_samples` + post-mortem inspection + - `04-monitor-topic-frequency.md` — `topic_metrics` + + `participant_events` with the opportunistic-fill caveat + Each example pairs an MCP-client prompt with the expected tool + calls and a short LLM-facing synthesis. + +#### Added (sub-milestone 5.3 — repo polish) + +- **`CONTRIBUTING.md`** (new). What contributions land easily vs hard, + development setup, the `make check` contract, mock-first + development convention, layer separation, pure-parser convention, + commit conventions. +- **`SECURITY.md`** (new). Local-trust threat model, the + read-only-by-architecture stance, vulnerability disclosure email, + response SLAs, supported version policy. +- **`.github/ISSUE_TEMPLATE/bug_report.yml`** (new) — structured form + with version, mode, OS, Python, repro, env vars, troubleshooting + check. +- **`.github/ISSUE_TEMPLATE/feature_request.yml`** (new) — + problem-first framing, tier disambiguation (OSS / Pro), explicit + read-only-by-architecture acknowledgment. +- **`.github/ISSUE_TEMPLATE/config.yml`** (new) — disables blank + issues, links to `SECURITY.md`, `docs/TROUBLESHOOTING.md`, + `docs/DDS_QUICKSTART.md`. +- **`.github/PULL_REQUEST_TEMPLATE.md`** (new) — summary, test plan, + backward-compatibility checklist (covers the 11-tool cap, schema + additive-only invariant, telemetry 6-field contract, env var docs, + public API removal flag). + +#### Notes + +- **Backward compat preserved.** Zero schema changes, zero new tools, + zero env-var renames. The `DDS_ONLY_ERROR_MSG` substring tokens + pinned by existing tests (`"DDS observability only"`, + `"TOPICFORGE_DDS_BACKEND"`, `"TOPICFORGE_MODE"`) are preserved + intact ; v0.4.0 producers and clients keep working byte-for-byte. +- **CHANGELOG entry deferred to release time.** This branch leaves + `pyproject.toml` at `0.4.0`, `__version__` at `"0.4.0"`, and the + `## [Unreleased]` heading populated with the polish notes above. + The version bump and `v0.5.0` tag are the maintainer's manual + steps after final review. + ## [0.4.0] - 2026-05-15 ### Sprint v0.4.0 — Phase 3 (bag analysis multi-format) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f90dcd3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,110 @@ +# Contributing to TopicForge + +Thanks for the interest. TopicForge is a small indie project run by one +maintainer ; the contribution loop is intentionally tight. + +## What contributions land easily + +- **Parser fixes for new ROS2 distros.** Pure parsers in + `src/topicforge/adapters/ros2_live/` are designed to be tweak-able + on a new distro without touching the adapter shell. A failing + parser test against Iron / Jazzy / Kilted with a 5-line fix is the + ideal contribution shape. +- **Bug reports with reproducers.** See + `.github/ISSUE_TEMPLATE/bug_report.yml`. A failing pytest case in + the body of the issue is gold. +- **Doc improvements.** Typos, broken links, unclear quickstart + steps. Open a PR directly — these merge fast. +- **Cross-platform regressions.** TopicForge is Windows-first ; if you + hit a Mac / Linux-specific breakage, file it with the stack trace. + +## What contributions are harder to land + +- **New MCP tools.** The tool surface is intentionally capped (11 as + of v0.4.0) — any expansion is a strategy decision documented in + `docs/product-plan.md §11` "Scope creep within the TopicForge + umbrella". File an issue describing the use case first ; the + maintainer will close, defer, or sponsor the work. +- **New backends.** The `RosAdapter` / `MiddlewareAdapter` protocol is + designed to take new adapters, but each one comes with a long-term + maintenance cost. File an issue with the use case ; expect a + conversation before the PR. +- **Refactors without a triggering bug or feature.** Cosmetic + refactors are a net cost for a solo maintainer. Tie any structural + change to a concrete user need. + +## Development setup + +Requires Python 3.11+. Tested on Python 3.11 / 3.12 / 3.13 on Ubuntu +and Windows. + +```bash +git clone https://github.com/yaniswav/TopicForge.git +cd TopicForge +python -m venv .venv +source .venv/bin/activate # Linux / macOS +# .venv\Scripts\Activate.ps1 # Windows PowerShell +pip install -e ".[dev]" +``` + +## The contract before a PR + +Every PR runs through CI on the matrix above. Locally, the bundle is : + +```bash +make check # lint + tests (Linux / macOS / WSL) +# or on Windows PowerShell: +python -m ruff check src tests +python -m ruff format --check src tests +python -m pytest -q +``` + +A green `make check` on Python 3.12 + the platform you developed on is +the baseline. CI catches the rest. + +### Mock-first development + +Every test must run without a real ROS2 install. The `mock` adapter +covers the full tool surface with deterministic fixtures. Tests that +need a binding declare a `requires_*` pytest marker and auto-skip +when the binding is absent — see `tests/test_cyclone_adapter.py` for +the pattern. + +### Layer separation + +The architecture is intentionally layered (`server/ → tools/ → +services/ → adapters/`). Tool handlers never call `subprocess` ; +adapters never validate MCP-level inputs ; services never know which +backend they're talking to. PRs that violate this earn a "rework" +review. + +### Pure parsers convention + +Parsing of `ros2` CLI output lives in module-level functions named +`parse_` separated from subprocess wrappers. They take a +string in, return a typed value out, and are tested without ROS2. +This is what makes new-distro fixes a 5-line patch instead of an +adapter rewrite. + +## Commit conventions + +- Short, imperative subject (under 70 chars). Lowercase initial verb is + fine. `fix: parse_csv_echo handles empty stamp` is the typical + shape. +- Body explains the *why*, not the *what* (the diff is the what). +- One concern per commit — no "fix tests + add feature + docs" bundles. +- We do not use the `Co-Authored-By: Claude` trailer, even when an AI + assistant was used. The contributor's authorship line is the + contract. + +## Releasing + +The maintainer handles releases. Tagging `v*` on `main` triggers the +`publish.yml` workflow → OIDC Trusted Publisher → PyPI. See +`.claude/skills/topicforge/release-checklist/` for the internal +checklist (not shipped to PyPI sdist). + +## Security + +Vulnerability disclosures go to the address in [SECURITY.md](SECURITY.md), +not to public issues. diff --git a/README.md b/README.md index 56ff73c..dcd4848 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # TopicForge [![PyPI version](https://img.shields.io/pypi/v/topicforge.svg)](https://pypi.org/project/topicforge/) +[![CI](https://github.com/yaniswav/TopicForge/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/yaniswav/TopicForge/actions/workflows/ci.yml) [![Python versions](https://img.shields.io/pypi/pyversions/topicforge.svg)](https://pypi.org/project/topicforge/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/yaniswav/TopicForge/blob/main/LICENSE) [![Read-only by architecture](https://img.shields.io/badge/safety-read--only_by_architecture-2563eb)](https://github.com/yaniswav/TopicForge#security-model) -> **The safety-first read-only MCP for ROS2 robotics — now with multi-vendor OMG DDS-RTPS observability (v0.3.0).** TopicForge lets AI agents inspect your ROS2 graph, ROS bag files, and (since v0.2.0) the raw DDS layer beneath ROS, without ever publishing back to the bus. v0.3.0 ships two OSS Python adapters — Eclipse CycloneDDS and eProsima Fast DDS — each joining the bus as a read-only DDS-RTPS participant that observes **every conformant vendor on the wire** (RTI Connext, OpenDDS, CoreDX, Dust DDS in Rust, etc.) via the OMG protocol guarantee. See [`docs/dds-interop-matrix.md`](docs/dds-interop-matrix.md) for the canonical multi-vendor positioning and the OMG May 2025 interop reference. +> **The safety-first read-only MCP for ROS2 robotics — observability + multi-vendor OMG DDS-RTPS (v0.4.0).** TopicForge lets AI agents inspect your ROS2 graph, recorded bag files, and the raw DDS layer beneath ROS — without ever publishing back to the bus. **Eleven typed read-only tools** (5 ROS2 graph + 3 DDS + 3 observability/bag) share a single Pydantic envelope so an LLM caller reads one schema across the whole stack. v0.4.0 adds participant lifecycle tracking (`participant_events`), temporal metrics (`topic_metrics`), and post-mortem bag sample peek (`peek_bag_samples`) on top of v0.3.0's two OSS Python DDS participants — Eclipse CycloneDDS and eProsima Fast DDS — each observing **every conformant vendor on the wire** (RTI Connext, OpenDDS, CoreDX, Dust DDS in Rust, etc.) via the OMG protocol guarantee. See [`docs/dds-interop-matrix.md`](docs/dds-interop-matrix.md) for the canonical multi-vendor positioning and the OMG May 2025 interop reference. TopicForge is a production-minded MCP (Model Context Protocol) server that lets AI agents — such as Claude — inspect ROS2 topics, analyze ROS bag files, and (since v0.2.0) observe the raw DDS layer through a clean, structured tool interface. It is read-only by **architecture**, not by configuration: there is no write path to misconfigure, no permission system to audit, no liability conversation to have. The MCP client can see the robot stack; it cannot touch it. @@ -171,21 +172,24 @@ CoreDX, InterCOM) ship under the optional `topicforge-pro` package with BYO vendor license. See [`docs/pro.md`](docs/pro.md) for the early-access slot and pricing terms ; nothing is collected today. -Three new MCP tools (in addition to the five ROS2 tools above) : +Six DDS / observability tools (in addition to the five ROS2 tools above) : -| Tool | Purpose | -| ----------------------- | -------------------------------------------------------------------------------- | -| `list_participants` | DDS participants discovered on a domain, with vendor and hostname | -| `detect_qos_mismatches` | Reader/writer QoS incompatibilities preventing communication on a topic | -| `peek_dds_samples` | Recent samples on a raw DDS topic (distinct from `sample_messages` on ROS2 graph)| +| Tool | Since | Purpose | +| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------- | +| `list_participants` | v0.2.0 | DDS participants discovered on a domain, with vendor, hostname, and (v0.4.0) lifecycle fields | +| `detect_qos_mismatches` | v0.2.0 | Reader/writer QoS incompatibilities preventing communication on a topic | +| `peek_dds_samples` | v0.2.0 | Recent samples on a raw DDS topic — v0.4.0 adds best-effort XTypes user-topic decode (distinct from `sample_messages` on ROS2 graph) | +| `participant_events` | v0.4.0 | Lifecycle stream — `discovered` / `lost` participant events over a configurable window | +| `topic_metrics` | v0.4.0 | Temporal metrics — observed frequency, sequence gaps, latency p50/p95/p99 over a sliding window | +| `peek_bag_samples` | v0.4.0 | Post-mortem inspection — decoded samples from a recorded `.mcap` / `.db3` / `.bag` file | -**Composite adapter (v0.4.0 Phase 1+).** When `TOPICFORGE_MODE=live` is paired with a DDS backend (`cyclone`, `fast`, …), TopicForge instantiates **both** a ROS2 CLI adapter and the chosen DDS adapter and routes per-tool category — the 5 ROS2 tools hit the CLI, the 3 DDS tools (+ `participant_events` from Phase 1) hit the DDS backend. ROS2-only or DDS-only setups still work — the missing half is skipped and the present half serves what it can. The mock backend continues to expose all 11 tools against deterministic fixtures for local development. +**Composite adapter (v0.4.0 Phase 1+).** When `TOPICFORGE_MODE=live` is paired with a DDS backend (`cyclone`, `fast`, …), TopicForge instantiates **both** a ROS2 CLI adapter and the chosen DDS adapter and routes per-tool category — the 5 ROS2 tools hit the CLI, the DDS / observability tools hit the DDS backend. ROS2-only or DDS-only setups still work — the missing half is skipped and the present half serves what it can. The mock backend continues to expose all 11 tools against deterministic fixtures for local development. -**v0.3.0 limitation — `peek_dds_samples` scope.** Full-fidelity on the 4 builtin DCPS topics (`DCPSParticipant`, `DCPSSubscription`, `DCPSPublication`) ; arbitrary user topics raise an `AdapterError` pointing at the v0.3.x XTypes/IDL roadmap. The other two DDS tools (`list_participants`, `detect_qos_mismatches`) work end-to-end on any user-topic deployment. +**`peek_dds_samples` payload shape (v0.4.0 Phase 1.5).** Full-fidelity on the 4 builtin DCPS topics (`DCPSParticipant`, `DCPSSubscription`, `DCPSPublication`). Arbitrary user topics return best-effort decoded samples with a `_decode_status` annotation : `"full"` (every IDL field decoded — currently a v0.4.0+ Cyclone XTypes path), `"partial"` (some fields decoded, others opaque), or `"raw"` (binding could not resolve the dynamic XTypes — bytes preserved as hex in `_raw_bytes_hex`). The diagnostic key `_decode_note` carries a short explanation when the status is non-`full`. The wire shape is identical across Cyclone and Fast backends. **`RTI Connext`** is v0.4.0+ Pro tier (BYO license — see `docs/pro.md`). -**Full 5-minute walkthrough** — backend selection, the canonical QoS-mismatch debugging scenario, troubleshooting — lives in [`docs/DDS_QUICKSTART.md`](docs/DDS_QUICKSTART.md). Migration from v0.2.0 in [`docs/MIGRATION_v0.2_to_v0.3.md`](docs/MIGRATION_v0.2_to_v0.3.md). +**Full 5-minute walkthrough** — backend selection, the canonical QoS-mismatch debugging scenario, troubleshooting — lives in [`docs/DDS_QUICKSTART.md`](docs/DDS_QUICKSTART.md). Migration history : [v0.2 → v0.3](docs/MIGRATION_v0.2_to_v0.3.md), [v0.3 → v0.4](docs/MIGRATION_v0.3_to_v0.4.md). ### Configure with Claude Desktop @@ -240,7 +244,7 @@ make check # both, plus tests (CI bundle) | `TOPICFORGE_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | | `TOPICFORGE_ROS2_BIN` | `ros2` | Name (or path) of the ROS2 CLI binary | | `TOPICFORGE_TELEMETRY` | `off` | Opt-in anonymous usage telemetry. See [Telemetry](#telemetry). | -| `TOPICFORGE_DDS_BACKEND` | `mock` | DDS module backend: `mock`, `cyclone`, `fast`, `rti`, or `auto`. `auto` resolves to Fast > Cyclone > Mock. See [Multi-vendor DDS support](#multi-vendor-dds-support-v030). | +| `TOPICFORGE_DDS_BACKEND` | `mock` | DDS module backend: `mock`, `cyclone`, `fast`, `rti`, `opensplice`, `coredx`, `intercom`, `opendds`, `dust`, or `auto`. The v0.4.0 Phase 1.5 auto-detect chain resolves to: `rti > opensplice > coredx > intercom` (Pro tier, if installed) `> opendds > fast > cyclone > dust > mock`. See [Multi-vendor DDS support](#multi-vendor-dds-support-v030). | | `TOPICFORGE_DDS_DOMAIN_ID` | `0` | DDS domain id observed (0..232) when a DDS backend is active. | See [`.env.example`](.env.example). @@ -268,7 +272,7 @@ When telemetry is on, each MCP tool call emits a single event with **only** thes | Field | Example | Notes | | ----------------- | ---------------- | ---------------------------------------------------------------------- | -| `tool_name` | `"list_topics"` | One of the five MVP tools — never argument values. | +| `tool_name` | `"list_topics"` | One of the eleven MCP tools — never argument values. | | `latency_ms` | `12.34` | Wall-clock duration of the handler, rounded to 2 decimals. | | `mode` | `"mock"` | Effective runtime mode: `mock` or `live`. | | `version` | `"0.1.2"` | TopicForge server version. | @@ -314,16 +318,14 @@ See [`docs/product-plan.md`](docs/product-plan.md) for the full product trajecto Near-term additions on the bench: -- `rclpy`-backed live adapter for faster & richer sampling -- XTypes/IDL discovery to extend `peek_dds_samples` to arbitrary user topics (today: 4 builtin DCPS topics only) — v0.3.x patch -- Extended QoS coverage (Liveliness, Ownership, Partition, TimeBasedFilter, LatencyBudget) — v0.3.x patch -- Composite adapter delegating per-tool category, so ROS2 + DDS surfaces work simultaneously — v0.3.x patch -- `RtiConnextAdapter` in the Pro tier (BYO RTI Connext license, gated by `TOPICFORGE_LICENSE_KEY`) — v0.4.0+ -- URDF inspector / validator MCP tools -- Bag anomaly detection (clock jumps, gaps, dropped frames, TF tree health) +- `rclpy`-backed live adapter for faster & richer sampling (per-message rmw receive timestamps, windowed sampling) — gated on external user demand +- Extended QoS coverage (Liveliness, Ownership, Partition, TimeBasedFilter, LatencyBudget) — v0.5.x patch +- Real `RtiConnextAdapter` in the Pro tier (BYO RTI Connext license, gated by `TOPICFORGE_LICENSE_KEY`) — the v0.4.0 Phase 1.5 framework is in place ; production binding pending Pro tier launch +- URDF inspector / validator MCP tools (Pro tier) +- Bag anomaly detection (clock jumps, gaps, dropped frames, TF tree health) — Pro tier - Dataset export helpers (rosbag → COCO / HF Datasets) - Synthetic data pipeline controller (Blender, Gazebo, Isaac Sim) -- Hosted MCP endpoint with auth +- Hosted MCP endpoint with auth (Phase 3 — depends on Pro tier traction) ## Project layout diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4874f24 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,80 @@ +# Security policy + +## Threat model — local trust by design + +TopicForge is **read-only by architecture**, not by configuration. +There is no write path in the protocol or in any shipped adapter ; +the MCP client can introspect a robot stack but cannot publish, +command, or modify anything. This is the load-bearing security +posture, validated by the audit trail in +`docs/projet-file/audit-followup-triage-v0.2.0.md`. + +The current threat model is **local trust**: TopicForge runs as a +subprocess of your MCP client (Claude Desktop, Claude Code) on a +machine you control, inspecting your own ROS2 graph or your own bag +files. It is not hardened for adversarial inputs. + +Consequences : + +- `TOPICFORGE_ROS2_BIN` accepts an arbitrary path — if you point it + at a malicious binary, TopicForge will execute it. Treat the + variable the way you treat `PATH`. +- `analyze_bag` opens whatever path the MCP client passes (no + workspace isolation, no symlink restriction). The threat model + assumes the client is your trusted agent acting on your behalf. +- All `ros2` CLI invocations use `subprocess.run` with an argument + list — never `shell=True`. Topic names are validated against a + strict allowlist before being passed to the CLI. +- No outbound network calls by default. Opt-in anonymous usage + telemetry is available behind `TOPICFORGE_TELEMETRY=on` ; when + off (the default), the OFF code path is a verified no-op (pinned + by `tests/test_telemetry.py::test_build_app_off_makes_no_transport_calls`). + +The roadmap to harden TopicForge for hosted / multi-tenant +deployments lives in +[`docs/product-plan.md §5`](docs/product-plan.md) under +"Audit-driven v0.3+ candidates" — `TOPICFORGE_ROS2_BIN` allowlist, +`subprocess.run` env scrub, `analyze_bag` workspace-root sandbox, +path traversal rejection, signed Pro plugin entry point. Those land +when the hosted MCP endpoint sprint (Phase 3) opens. + +## Reporting a vulnerability + +Please **do not open a public GitHub issue** for security reports. + +Email : `ethvignot.yanis@gmail.com` with subject prefix `[TopicForge +security]`. Include : + +- A short description of the issue (what fails, what could be + exploited, who is at risk). +- A reproduction if possible — versions, env vars, minimal sequence + of MCP tool calls. A failing pytest is ideal. +- Your preferred attribution wording for the public disclosure (or + "anonymous" if you prefer). + +Response timing : + +- **Acknowledgement** within 7 days. +- **Triage** (confirmed / not-a-bug / known-limitation) within 14 + days. +- **Patch + advisory** for confirmed vulnerabilities : timing + depends on severity. Critical issues get a patch release within 7 + days of triage ; lower-severity issues land in the next minor. + +If a vulnerability is in scope of the documented threat model (e.g. +"setting `TOPICFORGE_ROS2_BIN` to a malicious binary lets it run") I +will close it as "by design" with a pointer to this document. +Genuine threat-model gaps are in-scope and welcome. + +## Supported versions + +Only the latest minor release receives security patches. As of +2026-05-18, that's the `v0.4.x` line. Migrating from older versions +is generally a small effort — see the `docs/MIGRATION_v0.x_to_v0.y.md` +guides. + +## Disclosure + +For confirmed vulnerabilities, I publish a GitHub advisory after the +patch ships, naming the reporter (with permission) and including a +CVE if the impact warrants it. diff --git a/docs/DDS_QUICKSTART.md b/docs/DDS_QUICKSTART.md index fc918ed..466df51 100644 --- a/docs/DDS_QUICKSTART.md +++ b/docs/DDS_QUICKSTART.md @@ -1,4 +1,4 @@ -# DDS quickstart — TopicForge v0.3.0+ +# DDS quickstart — TopicForge v0.4.0+ A 5-minute tour of TopicForge's multi-vendor DDS observability module. Both backends — Eclipse CycloneDDS and eProsima Fast DDS — join the bus as **read-only DDS-RTPS participants** and observe every conformant vendor on the wire via the OMG protocol guarantee. The `MiddlewareAdapter` protocol does not expose a write method, so the MCP client cannot publish back to the bus on any backend. @@ -99,25 +99,25 @@ The analyzer covers the four MVP policies — **Reliability**, **Durability**, * --- -## 4. Single-adapter limitation (v0.3.0) +## 4. Composite adapter (v0.4.0 Phase 1+) -TopicForge v0.3.0 still selects **one adapter at a time** based on `TOPICFORGE_MODE` + `TOPICFORGE_DDS_BACKEND` : +v0.4.0 Phase 1 lifted the v0.3.0 single-adapter limitation. When `TOPICFORGE_MODE=live` is paired with a DDS backend, TopicForge instantiates **both** a `Ros2CliAdapter` and the chosen DDS adapter behind a `CompositeAdapter` and routes per-tool category — the 5 ROS2 graph tools hit the CLI, the 6 DDS / observability tools (`list_participants`, `detect_qos_mismatches`, `peek_dds_samples`, `participant_events`, `topic_metrics`, `peek_bag_samples`) hit the DDS half. The `name` collapses to `"ros2_cli+cyclone"` or `"ros2_cli+fast"` ; `effective_mode` reports `"live"` whenever either half is live. -| `TOPICFORGE_MODE` | `TOPICFORGE_DDS_BACKEND` | Active adapter | ROS2 tools | DDS tools | -| ----------------- | ------------------------ | -------------------------- | ------------------------- | -------------------------- | -| `mock` | (any) | `MockAdapter` | work (fixtures) | work (fixtures) | -| `live` / `auto` | `mock` (default) | `Ros2CliAdapter` | work | raise with remediation | -| `live` / `auto` | `cyclone` | `CycloneDdsAdapter` | raise (DDS-only adapter) | work (real CycloneDDS) | -| `live` / `auto` | `fast` | `FastDdsAdapter` | raise (DDS-only adapter) | work (real Fast DDS) | -| `live` / `auto` | `rti` | falls back to ROS2 CLI | work (CLI) | raise (v0.4.0+ Pro tier) | +| `TOPICFORGE_MODE` | `TOPICFORGE_DDS_BACKEND` | Active adapter | ROS2 tools | DDS / observability tools | +| ----------------- | ------------------------ | ----------------------------- | -------------------------------- | ----------------------------------- | +| `mock` | (any) | `MockAdapter` | work (fixtures) | work (fixtures) | +| `live` / `auto` | `mock` (default) | `Ros2CliAdapter` | work | raise with remediation | +| `live` / `auto` | `cyclone` | `CompositeAdapter(ros2_cli + cyclone)` | work (CLI) | work (real CycloneDDS) | +| `live` / `auto` | `fast` | `CompositeAdapter(ros2_cli + fast)` | work (CLI) | work (real Fast DDS) | +| `live` / `auto` | `rti` | falls back to `Ros2CliAdapter` | work (CLI) | raise (v0.4.0+ Pro tier — BYO license) | -A composite adapter that delegates per-tool category (ROS2 graph vs DDS layer) is on the v0.3.x roadmap. For now, restart the server with a different `TOPICFORGE_DDS_BACKEND` to switch sides. +**Graceful degradation paths preserved.** DDS binding missing → ROS2-CLI-only (the v0.3.0 behavior). ROS2 CLI missing on PATH → DDS-only adapter with a clear `DDS_ONLY_ERROR_MSG` on the 5 ROS2 methods. Neither available → MockAdapter (auto mode only). -Error messages on the unselected side are explicit and point at the remediation path — no silent failures. +`HealthReport` reports both halves via the `ros_backend` and `dds_backend` fields, so a downstream client can introspect which half of a composite is live without guessing. --- -## 5. v0.3.0 scope of `peek_dds_samples` +## 5. v0.4.0 scope of `peek_dds_samples` `peek_dds_samples` is full-fidelity on the 4 builtin DCPS topics with both backends : @@ -127,18 +127,26 @@ peek_dds_samples(topic="DCPSSubscription", count=10) peek_dds_samples(topic="DCPSPublication", count=10) ``` -Arbitrary user topics raise an `AdapterError` pointing at the v0.3.x roadmap — XTypes/IDL discovery (`cyclonedds.dynamic.get_types_for_typeid` on Cyclone, XTypes remote-type lookup on Fast DDS) is the missing piece for arbitrary user-topic peek. +**Arbitrary user topics (v0.4.0 Phase 1.5+).** The v0.3.0 `AdapterError` is retired. The tool now returns best-effort decoded samples annotated with a `_decode_status` field : -The other two DDS tools — `list_participants` and `detect_qos_mismatches` — work end-to-end on any user-topic deployment ; they don't depend on payload deserialization. +- `"full"` — every IDL field decoded (currently the Cyclone XTypes path, structurally in place ; real-bus validation pending user feedback) +- `"partial"` — some fields decoded, others opaque (mixed-success path) +- `"raw"` — the binding could not resolve the dynamic XTypes ; the serialized payload is preserved as hex in `_raw_bytes_hex` (capped at 4096 hex chars ; `_raw_bytes_truncated=True` flags clipping) + +The diagnostic key `_decode_note` carries a short explanation when the status is non-`full`. The wire shape is identical across Cyclone and Fast DDS — the analyzer doesn't need to know which backend produced the sample. + +Fast DDS 2.6.x exposes only a partial dynamic XTypes Python surface today, so the `"raw"` fallback is the common path on Fast DDS user topics — the structural plumbing is identical to Cyclone, the upstream binding completion is the gating factor. + +The other DDS tools — `list_participants`, `detect_qos_mismatches`, `participant_events`, `topic_metrics` — work end-to-end on any user-topic deployment ; they don't depend on payload deserialization. --- ## 6. What's next -- **v0.3.x patch** — XTypes/IDL discovery to extend `peek_dds_samples` to arbitrary user topics on both backends. -- **v0.3.x patch** — Extended QoS coverage : Liveliness, Ownership, Partition, TimeBasedFilter, LatencyBudget. -- **v0.3.x patch** — Composite adapter routing ROS2 graph tools to `Ros2CliAdapter` and DDS tools to the selected DDS adapter, so both surfaces are usable simultaneously. -- **v0.4.0+** — `RtiConnextAdapter` in the Pro tier (BYO RTI Connext license, gated by `TOPICFORGE_LICENSE_KEY`). +- **v0.5.x patch** — Fast DDS XTypes binding completion to lift `"raw"` → `"full"` for arbitrary user-topic peek on Fast. +- **v0.5.x patch** — Extended QoS coverage : Liveliness, Ownership, Partition, TimeBasedFilter, LatencyBudget. +- **v0.5.x patch** — Real-bus validation of the Cyclone XTypes pipeline (the v0.4.0 Phase 1.5 structural pipeline awaits user feedback on real domains). +- **v0.4.0+ Pro tier** — Real `RtiConnextAdapter` (BYO RTI Connext license, gated by `TOPICFORGE_LICENSE_KEY`). Scaffolded but not yet shipped. Full strategic roadmap lives in [`docs/product-plan.md`](product-plan.md) and the DDS module spec at [`docs/projet-file/mcp-02-spec.md`](projet-file/mcp-02-spec.md). @@ -149,8 +157,9 @@ Full strategic roadmap lives in [`docs/product-plan.md`](product-plan.md) and th - **`pip install topicforge[dds-cyclone]` fails on Windows / macOS Python 3.13+** — `cyclonedds` wheels are typically published for Python 3.8 to 3.12. Pin Python 3.11 or 3.12 for the install host. - **`pip install topicforge[dds-cyclone]` fails with `CYCLONEDDS_HOME`** — pip is trying to build `cyclonedds` from source because no wheel matches your platform/Python combination. Either switch to a supported Python (3.11/3.12) or install the native CycloneDDS C library first (see Eclipse CycloneDDS releases). - **`pip install topicforge[dds-fast]` fails** — eProsima Fast DDS Python bindings (`fastdds>=2.6.1,<3`) currently ship wheels for Linux first. Windows wheels lag ; consult fast-dds.docs.eprosima.com for the current matrix. -- **DDS tool returns "v0.3.x roadmap" error** — you called `peek_dds_samples` on an arbitrary user topic. The 4 builtin DCPS topics work today ; arbitrary user-topic peek is a v0.3.x patch (XTypes/IDL discovery). -- **DDS tool returns "DDS module is not active" error** — your `TOPICFORGE_DDS_BACKEND` is `mock` while `TOPICFORGE_MODE` is `live` (the ROS2 CLI adapter is selected). Set `TOPICFORGE_DDS_BACKEND=cyclone` or `=fast` explicitly to enable the DDS adapters. +- **DDS tool returns samples with `_decode_status="raw"`** — the binding could not resolve the dynamic XTypes for this user topic. Inspect `_decode_note` for the cause and `_raw_bytes_hex` for the serialized payload. On Fast DDS this is the common path until 2.6.x dynamic XTypes binding completion. On Cyclone, ensure the publisher uses XTypes-discoverable types and re-run. +- **DDS tool returns "DDS module is not active" error** — your `TOPICFORGE_DDS_BACKEND` is `mock` while `TOPICFORGE_MODE` is `live` (the ROS2 CLI half of the composite is the only one selected). Set `TOPICFORGE_DDS_BACKEND=cyclone` or `=fast` explicitly to enable the DDS half — the `CompositeAdapter` will then serve both surfaces. +- **DDS tool returns "DDS observability only" with a long remediation message** — the inverse case: a DDS-only adapter is active (the ROS2 CLI is missing on PATH) and you called a ROS2 graph tool. Install ROS2 and source the workspace so `ros2` is on PATH ; the `CompositeAdapter` will pick up both halves on the next run. - **`auto` selects the wrong backend** — `auto` prefers Fast > Cyclone > Mock. If you want Cyclone explicitly, set `TOPICFORGE_DDS_BACKEND=cyclone` rather than relying on `auto`. Report issues at https://github.com/yaniswav/TopicForge/issues. diff --git a/docs/MIGRATION_v0.3_to_v0.4.md b/docs/MIGRATION_v0.3_to_v0.4.md new file mode 100644 index 0000000..1bc2f61 --- /dev/null +++ b/docs/MIGRATION_v0.3_to_v0.4.md @@ -0,0 +1,242 @@ +# Migrating from TopicForge v0.3.0 to v0.4.0 + +Reading time : 8 minutes. v0.4.0 is the **observability + bag-analysis maturation** release. The tool surface grows from 8 to 11 ; the v0.3.0 single-adapter limitation is lifted by `CompositeAdapter` ; the DDS auto-detect chain widens from 3 to 8 vendor candidates ; `peek_dds_samples` no longer raises on arbitrary user topics. Every v0.3.0 producer keeps working — schema changes are additive optional, env vars are widened not replaced. + +--- + +## Who needs to read this + +| Setup | Action | +| ----- | ------ | +| You use TopicForge through Claude Desktop / Claude Code / Cursor / Cline with `pip install topicforge` and no custom code | **Read §1 and §2 only.** Everything else is producer-side. | +| You import `topicforge` modules in your own Python code | **Read all sections.** Three new MCP tools, four `ParticipantInfo` lifecycle fields, four `BagAnalysis` enrichment fields, and a new `MiddlewareAdapter` method group. | +| You validate MCP responses against a pinned JSON Schema with `additionalProperties: false` | **Read §3 carefully.** `ParticipantInfo`, `BagAnalysis`, `HealthReport`, `AdapterName`, `DdsBackend` all widened. | +| You ran TopicForge v0.3.0 with `TOPICFORGE_MODE=live` + `TOPICFORGE_DDS_BACKEND=cyclone` and got DDS-only-mode errors on the 5 ROS2 tools | **§4 — the CompositeAdapter now serves both surfaces simultaneously.** Source ROS2 and re-run ; the errors disappear. | +| You ran TopicForge v0.3.0 against arbitrary user topics with `peek_dds_samples` and got the "v0.3.x roadmap" `AdapterError` | **§5 — the error is gone.** The tool now returns best-effort decoded samples with a `_decode_status` annotation. | + +--- + +## 1. Three new MCP tools (additive) + +v0.4.0 explicitly breaks the documented 8-tool ceiling three times — each break is acknowledged in `docs/projet-file/mcp-02-spec.md §2` and in the CHANGELOG `[0.4.0]` section. + +- **`participant_events(domain_id, lookback_seconds)`** (Phase 1) — DDS participant `discovered` / `lost` events over a configurable window. Default lookback 300 s, range 1..86400, hard cap 200 events newest-first. Backed by the new `LifecycleBuffer` shared between Cyclone (polling reconciliation) and Fast DDS (listener callbacks). +- **`topic_metrics(topic, window_seconds, domain_id)`** (Phase 2) — temporal metrics : observed frequency, sequence gaps, latency p50/p95/p99 over a sliding window. Default window 60 s, range 1..3600. Buffer cap `MAX_SAMPLES_PER_TOPIC=1000`, drop-oldest. Opportunistic fill : the metrics buffer accumulates ONLY as `peek_dds_samples` is exercised (Cyclone + Fast 2.6.x Python bindings do not expose at-sample-receive callbacks). +- **`peek_bag_samples(path, topic, count)`** (Phase 3) — post-mortem inspection : decoded samples from a recorded `.mcap` / `.db3` / `.bag` file. Distinct from `peek_dds_samples` (live bus) and `sample_messages` (ROS2 graph live peek). Same `SampleResult` envelope as the other two so an LLM caller reads one schema. Requires `pip install topicforge[bags]` (rosbags Apache 2.0 pure-Python library). + +If you maintain a local tool allowlist for the MCP client, add these three names. If you pin against the `tests/test_tools_integration.py::MVP_TOOLS` set, it grew from 8 to 11. + +--- + +## 2. New / changed environment variables and extras + +### 2.1 `TOPICFORGE_DDS_BACKEND` accepts 5 new vendor values + +The v0.4.0 Phase 1.5 auto-detect chain widens the Literal : + +``` +v0.3.0 accepted : mock | cyclone | fast | rti | auto +v0.4.0 accepted : mock | cyclone | fast | rti | opensplice | coredx | intercom | opendds | dust | auto +``` + +The 5 new values target the OMG vendor space : + +- `opendds` and `dust` ship as **OSS stubs** — `is_available()` returns False because the upstream Python bindings (`pyopendds`, `dust-dds-python`) are not on PyPI yet. The extras `[dds-opendds]` and `[dds-dust]` are placeholder pins anchoring the auto-detect probe for the day the upstream packages ship. `pip install topicforge[dds-opendds]` today produces a clean install failure. +- `opensplice`, `coredx`, `intercom` are **Pro tier targets** — probed against `topicforge_pro.adapters.` rather than the upstream SDK. The OSS core never imports a commercial vendor binding. + +### 2.2 `auto` resolution chain widened + +``` +v0.3.0 : Fast > Cyclone > Mock +v0.4.0 : RTI > OpenSplice > CoreDX > InterCOM (Pro tier, if installed) + > OpenDDS > Fast > Cyclone > Dust > Mock (OSS) +``` + +**Backward compat** : Pro tier candidates are only considered when the `topicforge_pro` package is importable on the host. v0.3.0 users without Pro see the chain collapse to `OpenDDS > Fast > Cyclone > Dust > Mock`. Since `opendds` and `dust` are stubs that report `is_available()=False`, the practical effective order remains `Fast > Cyclone > Mock` — unchanged from v0.3.0 unless you've explicitly added a Pro tier package. + +### 2.3 New pyproject extras + +- `[bags]` — `rosbags>=0.9` (Phase 3, optional bag analysis). **Not** bundled in `[all]` to keep the default footprint small. +- `[dds-opendds]` — `pyopendds>=0.1` placeholder pin (Phase 1.5). +- `[dds-dust]` — `dust-dds-python>=0.1` placeholder pin (Phase 1.5). +- `[dds-all-oss]` — `topicforge[dds] + dds-opendds + dds-dust` for users opting into the stubs. + +### 2.4 New pytest markers + +- `integration` — real-bus DDS scenarios. Gated out of the default invocation ; run with `pytest -m integration` or the labeled CI workflow. +- `requires_opendds` — auto-skip without the binding (same convention as `requires_cyclonedds`). +- `requires_dust` — same shape. +- `requires_rosbags` — Phase 3 bag tests. + +--- + +## 3. Soft-breaking schema changes + +All changes are **additive optional with safe defaults** ; every v0.3.0 producer keeps working. Strict MCP clients pinned to v0.3.0 JSON Schemas with `additionalProperties: false` need to regenerate, like at every minor. + +### 3.1 `ParticipantInfo` — 4 lifecycle fields + +```python +# v0.3.0 +class ParticipantInfo(BaseModel): + guid: str + vendor: Literal["cyclone", "fast", "rti", "mock", "unknown"] + hostname: str | None = None + domain_id: int + mode_effective: Literal["mock", "live"] + +# v0.4.0 +class ParticipantInfo(BaseModel): + guid: str + vendor: Literal["cyclone", "fast", "rti", "mock", "unknown"] + hostname: str | None = None + domain_id: int + mode_effective: Literal["mock", "live"] + first_seen_ns: int | None = None # NEW (Phase 1) + last_seen_ns: int | None = None # NEW (Phase 1) + status: Literal["active", "left", "unknown"] = "unknown" # NEW (Phase 1) + seen_count: int = 0 # NEW (Phase 1) +``` + +Backed by `LifecycleBuffer` on Cyclone (polling reconciliation) and Fast DDS (listener-callback native, including `lost` events). + +### 3.2 `BagAnalysis` — 4 enrichment fields + +```python +# v0.4.0 +class BagAnalysis(BaseModel): + # v0.3.0 fields unchanged + bag_format: Literal["mcap", "db3", "bag", "unknown"] | None = None # NEW (Phase 3) + samples_decoded_count: int = 0 # NEW (Phase 3) + recording_duration_ns: int | None = None # NEW (Phase 3) + participants_recorded: list[ParticipantInfo] = [] # NEW (Phase 3) +``` + +`analyze_bag` retains the v0.3.0 `ros2 bag info` text-parse fallback on `Ros2CliAdapter` when rosbags is absent ; the enriched fields populate at their safe defaults in that path. + +### 3.3 `HealthReport.ros_backend` — new field + +```python +# v0.4.0 +ros_backend: Literal["mock", "ros2_cli", "none"] = "none" # NEW (Phase 1) +``` + +Symmetric to the existing `dds_backend`. Lets clients distinguish the ROS and DDS halves of a `CompositeAdapter` without reading the `name` tag. + +### 3.4 `HealthReport.dds_backend` Literal widened + +``` +v0.3.0 : "mock" | "cyclone" | "fast" | "rti" | "none" +v0.4.0 : "mock" | "cyclone" | "fast" | "rti" | "opensplice" | "coredx" | "intercom" | "opendds" | "dust" | "none" +``` + +### 3.5 `AdapterName` Literal widened + +Internal type (no MCP-wire impact), but listed for code-level type-checkers. Now includes 5 new vendor tags (`opensplice`, `coredx`, `intercom`, `opendds`, `dust`) and 7 composite tags (`ros2_cli+cyclone`, `ros2_cli+fast`, `ros2_cli+rti`, `ros2_cli+opensplice`, `ros2_cli+coredx`, `ros2_cli+intercom`, `ros2_cli+opendds`). + +### 3.6 `TopicMetrics` schema (new — Phase 2) + +```python +class TopicMetrics(BaseModel): + topic: str + window_seconds: int + samples_observed: int + frequency_hz_observed: float | None + frequency_hz_declared: float | None # from QoS Deadline + sequence_gaps_count: int | None + latency_ns_p50: int | None + latency_ns_p95: int | None + latency_ns_p99: int | None + # ... + boolean availability flags per conditional metric + mode_effective: Literal["mock", "live"] +``` + +Frozen, `extra="forbid"`. The None / 0 semantics surface partial-data scenarios cleanly to LLM callers. + +### 3.7 `ParticipantEvent` schema (new — Phase 1) + +```python +class ParticipantEvent(BaseModel): + timestamp_ns: int + event_type: Literal["discovered", "lost"] + participant: ParticipantInfo + mode_effective: Literal["mock", "live"] +``` + +--- + +## 4. `CompositeAdapter` — the single-adapter limitation is lifted + +v0.3.0 selected one adapter at a time : `Ros2CliAdapter` OR a DDS adapter. The 5 ROS2 tools worked on the former and raised `AdapterError(DDS_ONLY_ERROR_MSG)` on the latter ; the 3 DDS tools worked on the latter and raised the inverse error on the former. + +v0.4.0 Phase 1 ships `CompositeAdapter` (`adapters/composite.py`). When `TOPICFORGE_MODE=live` is paired with a DDS backend, the factory tries to build **both** halves and wraps them in a composite that routes per-tool category : + +- The 5 ROS2 graph tools (`list_topics`, `get_topic_info`, `sample_messages`, `analyze_bag`, `peek_bag_samples`) hit `Ros2CliAdapter`. +- The 6 DDS / observability tools (`list_participants`, `detect_qos_mismatches`, `peek_dds_samples`, `participant_events`, `topic_metrics`) hit the selected DDS adapter. + +The composite's `name` collapses to `"ros2_cli+cyclone"` or `"ros2_cli+fast"`. `effective_mode` reports `"live"` whenever either half is live. + +**Graceful degradation paths preserved** : + +- DDS binding missing → `Ros2CliAdapter` alone (the v0.3.0 fallback). +- ROS2 CLI missing on PATH → DDS-only adapter with the polished `DDS_ONLY_ERROR_MSG` on the 5 ROS2 methods. The v0.5.0 message lists the affected tools and points at the `CompositeAdapter` remediation. +- Neither available → `MockAdapter` (auto mode only). + +If your deployment relied on the v0.3.0 error to detect "DDS adapter is selected", switch to inspecting `HealthReport.dds_backend` and `ros_backend` instead — both are populated correctly when a composite is live. + +--- + +## 5. `peek_dds_samples` on user topics — no more `AdapterError` + +v0.3.0 raised `AdapterError("v0.3.x roadmap — XTypes/IDL discovery missing")` for any non-builtin topic. v0.4.0 Phase 1 returns best-effort decoded samples with three reserved annotation keys : + +- `_decode_status` : `"full"` / `"partial"` / `"raw"` +- `_decode_note` : short diagnostic when the status is non-`full` +- `_raw_bytes_hex` : hex-encoded serialized payload preview when `_decode_status="raw"` (capped at 4096 hex chars ; `_raw_bytes_truncated=True` flags clipping) + +The wire shape is identical across Cyclone and Fast DDS. Phase 1.5 added the Cyclone XTypes pipeline ; Fast DDS 2.6.x bindings still ship a partial dynamic XTypes Python surface so the `"raw"` fallback is the common path on Fast user topics — the structural plumbing is identical, the upstream binding completion is the gating factor. + +If your code parsed the v0.3.0 `AdapterError` text to detect this case, replace the `try/except` with a `samples[i].payload["_decode_status"]` check. + +--- + +## 6. `MiddlewareAdapter` protocol expansions + +Three new methods on the protocol : + +```python +def participant_events( + self, domain_id: int, lookback_seconds: int +) -> list[ParticipantEvent]: ... + +def topic_metrics( + self, topic: str, window_seconds: int, domain_id: int +) -> TopicMetrics: ... + +def peek_bag_samples( + self, path: str, topic: str, count: int +) -> SampleResult: ... +``` + +All existing adapters implement them. Mock returns deterministic fixtures. `Ros2CliAdapter.peek_bag_samples` delegates to `BagService`. DDS-only adapters (`Cyclone`, `Fast`, `OpenDDS`, `Dust`) raise their existing `DDS_ONLY_ERROR_MSG` on `peek_bag_samples`. `Ros2CliAdapter`, `OpenDDS`, `Dust` raise their existing roadmap errors on `participant_events` and `topic_metrics`. + +If you implement a custom adapter (third-party `MiddlewareAdapter` shim, integration scaffold), add the three methods or your adapter will fail protocol conformance at type-check time. + +--- + +## 7. No code change to the 5 ROS2 tools + +`health_check`, `list_topics`, `get_topic_info`, `sample_messages`, `analyze_bag` behave identically to v0.3.0 — except `analyze_bag` now populates the new `BagAnalysis` enrichment fields when rosbags is installed (and leaves them at safe defaults otherwise). The `mode_effective` wire contract is unchanged. + +--- + +## 8. Quick checklist + +- [ ] Verified the eleven-tool set is allowlisted in your MCP client config (if you allowlist). +- [ ] Regenerated any pinned JSON Schemas for `ParticipantInfo`, `BagAnalysis`, `HealthReport`. +- [ ] Removed any v0.3.x `try/except AdapterError` around `peek_dds_samples` on user topics — replace with `_decode_status` checks. +- [ ] Confirmed `pip install topicforge[bags]` is added wherever `peek_bag_samples` is exercised. +- [ ] If you implemented a custom `MiddlewareAdapter`, added `participant_events`, `topic_metrics`, `peek_bag_samples`. +- [ ] Read the polished `DDS_ONLY_ERROR_MSG` once — its wording changed in v0.5.0 polish. + +Questions or migration friction : open an issue at https://github.com/yaniswav/TopicForge/issues. diff --git a/docs/TESTING.md b/docs/TESTING.md index 365e52c..511eae8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,21 +14,30 @@ If you find a step that doesn't work, please [open an issue](https://github.com/ | You want to... | Time | Path | | --- | --- | --- | -| See the five MCP tools work end-to-end without installing ROS2 | 5 min | [Path 1 — Mock mode](#path-1--mock-mode-no-ros2-required) | +| See the eleven MCP tools work end-to-end without installing ROS2 | 5 min | [Path 1 — Mock mode](#path-1--mock-mode-no-ros2-required) | | Validate live mode against real ROS2 traffic on Windows | 45 min | [Path 2 — WSL2 + Humble](#path-2--wsl2--ros2-humble-windows-recommended) | | Same as Path 2, but you're already on Ubuntu/Debian | 20 min | [Path 3 — Linux native](#path-3--linux-native) | | Reproducible throwaway environment | 15 min | [Path 4 — Docker](#path-4--docker-throwaway) | | Native Windows ROS2 install (no virtualization) | 1–2 h | [Path 5 — Windows native (advanced)](#path-5--windows-native-advanced) | Once any path is set up, jump to [Test scenarios](#test-scenarios) to -exercise the five tools, then [Connect an MCP client](#connect-an-mcp-client) +exercise the eleven tools, then [Connect an MCP client](#connect-an-mcp-client) to use TopicForge from Claude Desktop, Claude Code, Cursor, etc. +> **Tool surface as of v0.4.0.** 5 ROS2 graph tools (`health_check`, +> `list_topics`, `get_topic_info`, `sample_messages`, `analyze_bag`) + +> 3 DDS tools (`list_participants`, `detect_qos_mismatches`, +> `peek_dds_samples`) + 3 observability tools (`participant_events`, +> `topic_metrics`, `peek_bag_samples`). The mock adapter serves all +> eleven against deterministic fixtures so no DDS broker is required +> for evaluation. See [`docs/DDS_QUICKSTART.md`](DDS_QUICKSTART.md) for +> the DDS half. + --- ## Path 1 — Mock mode (no ROS2 required) -The fastest way to confirm the server starts, registers all five tools, +The fastest way to confirm the server starts, registers all eleven tools, and serves typed payloads to an MCP client. ```bash diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..beaec9a --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,245 @@ +# Troubleshooting TopicForge + +Common errors, what they mean, and the remediation path. Each section +mirrors a polished `AdapterError` message from v0.5.0 so you can search +for a substring of the error text and land in the right place. + +If your situation isn't covered here, open an issue at +[github.com/yaniswav/TopicForge/issues](https://github.com/yaniswav/TopicForge/issues) +— the troubleshooting list grows from real reports. + +--- + +## "DDS observability only" / `DDS_ONLY_ERROR_MSG` + +**Full message** (paraphrased): + +> This adapter serves DDS observability only — it cannot run the ROS2 +> graph tools (`list_topics`, `get_topic_info`, `sample_messages`, +> `analyze_bag`, `peek_bag_samples`). To get both surfaces in one +> process: install ROS2 and source the workspace so `ros2` is on PATH, +> then re-run with `TOPICFORGE_MODE=live` — the v0.4.0 CompositeAdapter +> routes ROS2 tools to the CLI and DDS tools to your +> `TOPICFORGE_DDS_BACKEND` automatically. For offline development use +> `TOPICFORGE_MODE=mock`. + +**What it means.** You called a ROS2 graph tool but only a DDS adapter +is active in this process. In v0.4.0 the `CompositeAdapter` is the +intended way to get both surfaces simultaneously, and it only engages +when **both** the ROS2 CLI **and** the chosen DDS backend can be +brought up. + +**How to fix.** + +1. Confirm `ros2 --help` works in the same shell that spawns TopicForge + (sourcing `/opt/ros//setup.bash` on Linux ; running + `call C:\dev\ros2_humble\local_setup.bat` on Windows native ; + ensuring WSL has ROS2 sourced if you're going through WSL). +2. Re-run with `TOPICFORGE_MODE=live` and your existing + `TOPICFORGE_DDS_BACKEND`. The factory will detect both halves and + construct a `CompositeAdapter`. +3. Inspect `health_check` — `ros_backend` should now be `"ros2_cli"` + and `dds_backend` should be your selected vendor. + +If you genuinely want a DDS-only deployment (no ROS2), use +`TOPICFORGE_MODE=mock` for offline development or stick with the DDS +adapter and only call the 6 DDS / observability tools +(`list_participants`, `detect_qos_mismatches`, `peek_dds_samples`, +`participant_events`, `topic_metrics`, `peek_bag_samples`). + +--- + +## "rosbags" / `_ROSBAGS_REQUIRED_MSG` + +**Full message** (paraphrased): + +> Bag analysis with full sample decode requires the `rosbags` library. +> Install via `pip install topicforge[bags]` and retry. `analyze_bag` +> may still fall back to the v0.3.0 `ros2 bag info` text-parse path via +> the live ROS2 CLI adapter ; `peek_bag_samples` has no fallback. + +**What it means.** You called `peek_bag_samples` (or `analyze_bag` on +a non-CLI path) without the optional `rosbags` Apache-2.0 library +installed. The library is intentionally optional — base +`pip install topicforge` keeps the install footprint small. + +**How to fix.** + +```bash +pip install topicforge[bags] +``` + +This pulls `rosbags>=0.9` (pure-Python, no native deps, works on all +supported Python/OS combos). Re-run the tool — no env-var change +needed. + +If you cannot install rosbags (sandboxed CI, restricted package +allowlist, etc.), `analyze_bag` still works via the v0.3.0 `ros2 bag +info` text-parse path when run through `Ros2CliAdapter` ; the enriched +fields populate at safe defaults. `peek_bag_samples` is rosbags-only. + +--- + +## "CycloneDDS participant discovery failed" + +**Full message** (paraphrased): + +> CycloneDDS participant discovery failed on domain `{id}` ({exception +> class}: {exception message}). Common causes: DDS domain mismatch, +> firewall blocking RTPS multicast, or `CYCLONEDDS_URI` pointing at an +> unreadable config. + +**Diagnostics, in order.** + +1. **Domain mismatch.** TopicForge joins the domain set by + `TOPICFORGE_DDS_DOMAIN_ID` (default `0`). Your publishers must be + on the same domain. Check with `echo $ROS_DOMAIN_ID` or by reading + the publisher's config — they must match TopicForge's domain id. +2. **Firewall / multicast.** RTPS uses multicast on `239.255.0.x` by + default. If your firewall blocks multicast (common on corp Wi-Fi), + discovery times out silently. Either allow multicast on the + interface (`sudo iptables -A INPUT -d 224.0.0.0/4 -j ACCEPT` on + Linux for a quick test) or configure CycloneDDS for unicast + discovery via `CYCLONEDDS_URI`. +3. **`CYCLONEDDS_URI` misconfigured.** If you've set this env var, make + sure it points to a readable XML file. `unset CYCLONEDDS_URI` to + confirm whether the var itself is the culprit. + +The error message carries the underlying Python exception type and +text — that's usually the most informative starting point. A `Timeout` +exception points at #1 or #2 ; a `FileNotFoundError` or `OSError` +points at #3. + +--- + +## "Fast DDS DomainParticipant creation returned None" + +**Full message** (paraphrased): + +> Fast DDS DomainParticipant creation returned None on domain `{id}`. +> Likely an ABI mismatch between the `fastdds` Python binding and the +> installed Fast DDS core library — pin `fastdds>=2.6.1,<3` and +> reinstall, or check the `FastDDS_DEFAULT_PROFILES_FILE` env var if +> you set one. + +**What it means.** `DomainParticipantFactory.create_participant()` +returned `None` instead of raising. Almost always an ABI mismatch +between the Python `fastdds` wheel and the native `libfastdds` shared +library on the host. + +**How to fix.** + +1. Reinstall with the pinned version : + ```bash + pip uninstall -y fastdds + pip install "fastdds>=2.6.1,<3" + ``` +2. If you set `FastDDS_DEFAULT_PROFILES_FILE`, unset it and retry — a + broken profile XML triggers the same symptom. +3. If you have a system-wide Fast DDS native install (CMake / vcpkg / + apt), make sure `LD_LIBRARY_PATH` (or `PATH` on Windows) does not + conflict with the pip wheel's bundled shared library. + +Fast DDS 3.x binding wheels are not yet stable on Python 3.11+ for +Windows / Linux as of v0.4.0 ; TopicForge pins to the 2.6.x line in +`pyproject.toml`. Stick with that pin until you see the v0.6 CHANGELOG +note bumping it. + +--- + +## "domain_id must be in 0..232" + +**What it means.** DDS domain ids are spec-bounded to `[0, 232]`. You +passed something outside that range. + +**How to fix.** Pass an int in range. `0` is the ROS2 default ; most +production setups use `<= 100`. + +--- + +## "lookback_seconds must be in 1..86400" + +**What it means.** `participant_events` accepts a window from 1 second +to 86400 seconds (24 hours). The hard cap on the lifecycle ring buffer +is 200 events newest-first regardless of window, so very long windows +on a busy bus may not surface the oldest events. + +**How to fix.** Pass a value in range, or omit the argument to take +the 300 s default. + +--- + +## "window_seconds must be in 1..3600" + +**What it means.** `topic_metrics` accepts a window from 1 second to +3600 seconds (1 hour). The `MetricsBuffer` cap is +`MAX_SAMPLES_PER_TOPIC=1000` drop-oldest, which on a 1 kHz topic +covers about 1 second of data — be aware that the observed frequency +is computed from buffered samples, not from a true rolling time +window. + +**How to fix.** Pass a value in range, or omit the argument to take +the 60 s default. + +--- + +## "count must be >= 0" + +Trivial — the parameter accepts non-negative integers only. Most +sampling tools silently clamp to `MAX_SAMPLE_COUNT=50` so a request +for `1000` returns 50 with the actual `SampleResult.count` field +reflecting the truth. + +--- + +## "DDS topic name is malformed" + +**What it means.** You passed a topic string that doesn't match the +relaxed DDS regex `^[A-Za-z_/][A-Za-z0-9_/:]*$`. This validator +accepts the OMG-DDS conventions (no leading `/` required, `::` +separators allowed, builtin DCPS names like `DCPSParticipant`) but +still rejects whitespace, shell metacharacters, and dashes. + +**How to fix.** Strip dashes / spaces from the topic name. ROS2 topic +names always start with `/` and use `_` (never `-`) for word +separation, so the typical fix is `my-topic` → `my_topic` or +`/my_topic`. + +--- + +## "ROS2 CLI not found on PATH" / mode falls back to `mock` + +**Symptom.** `health_check` reports `mode_effective: "mock"` even +though you set `TOPICFORGE_MODE=live` or `auto`. + +**Diagnostics.** + +1. From the **same shell** that spawned TopicForge (this is critical + for desktop MCP clients — PATH and venv activation are not + inherited across GUI launchers) : + ```bash + which ros2 # Linux / WSL — should print /opt/ros//bin/ros2 + where.exe ros2 # Windows — should print a .cmd / .bat path + ``` +2. If `ros2` is not on PATH, source the ROS2 setup file in the parent + shell **before** launching the MCP client. +3. Override the binary explicitly : + ```bash + TOPICFORGE_ROS2_BIN=/opt/ros/humble/bin/ros2 python -m topicforge + ``` + +On Windows native, `ros2.cmd` is what `shutil.which` resolves — +TopicForge handles the shell-shim resolution. Make sure the install +directory containing `ros2.cmd` is on `%PATH%`. + +--- + +## Where to look next + +- [README.md](../README.md) — install, run, configure +- [docs/DDS_QUICKSTART.md](DDS_QUICKSTART.md) — 5-minute DDS walkthrough +- [docs/TESTING.md](TESTING.md) — five-path setup guide +- [docs/MIGRATION_v0.3_to_v0.4.md](MIGRATION_v0.3_to_v0.4.md) — most recent migration +- [docs/product-plan.md](product-plan.md) — strategic roadmap (Phase 3 hosted endpoint reopens many security caveats) + +Open issues : https://github.com/yaniswav/TopicForge/issues. diff --git a/docs/projet-file/audit-followup-triage-v0.2.0.md b/docs/projet-file/audit-followup-triage-v0.2.0.md index 914f085..aace0c5 100644 --- a/docs/projet-file/audit-followup-triage-v0.2.0.md +++ b/docs/projet-file/audit-followup-triage-v0.2.0.md @@ -1,7 +1,12 @@ -# Audit follow-up triage — v0.2.0 +# Audit follow-up triage — v0.2.0 (refreshed v0.5.0) Adoption-prep sprint, 2026-05-14. Triage of the two pre-v0.2.0 audits. +> **2026-05-18 refresh (v0.5.0 polish sprint).** Each item is re-checked +> against the current tree. A items shipped during v0.2.0 adoption-prep +> ; six of the nine B items are now closable, leaving three that stay +> deferred. See the "Status v0.5.0" column on each item. + ## Source rapports - `security-audit-v0.1.2.md` — branche `audit/security`, SHA `ae80df3` @@ -11,53 +16,59 @@ Scoped sections (per sprint brief): security "Hardening opportunities" (6 bullet --- -## A — Address now (in this sprint) +## A — Address now (in this sprint) — all shipped v0.2.0 ### A1 — Document `HealthReport.ros2_distro` as env disclosure by design - **Origine** : security audit, "Hardening opportunities", bullet 6 (line ~22 of report). - **Description** : `health.py:29` reads `ROS_DISTRO` from the parent env and returns it verbatim in `HealthReport`. Low-sensitivity but reachable by any MCP client. - **Fix prévu** : Add an explicit note to `HealthReport.ros2_distro` field description in `models/schemas.py` flagging the env-disclosure as intentional under the local-trust threat model. No behavior change. +- **Status v0.5.0** : **CLOSED in v0.2.0** — see `models/schemas.py` `HealthReport.ros2_distro` Field description. ### A2 — Document `mode_effective` asymmetry across response models - **Origine** : architecture audit, "⚠️ Refactor opportunities", item 6. - **Description** : `mode_effective` is on `TopicInfo` / `SampleResult` / `BagAnalysis` but absent from `HealthReport` and `MessageSample`. Asymmetry defensible (Health reports mode via its own fields ; samples nest inside `SampleResult`) but undocumented. - **Fix prévu** : One-line note appended to `_MODE_EFFECTIVE_DESC` constant in `models/schemas.py` explaining the asymmetry. No behavior change. +- **Status v0.5.0** : **CLOSED in v0.2.0** — note present at `models/schemas.py:_MODE_EFFECTIVE_DESC`. ### A3 — Update stale `TODO(roadmap)` on `parse_echo_yaml` - **Origine** : architecture audit, "⚠️ Refactor opportunities", item 8 (and "Conventions audit" WARN line). - **Description** : `parse_echo_yaml` is no longer called by the live adapter since v0.1.2 (replaced by `parse_csv_echo`). The TODO(roadmap) at `adapters/ros2_live/adapter.py:244` says `rclpy` will obsolete it — already obsoleted by `parse_csv_echo`. The function is still tested (`tests/test_live_adapter_parse.py`). - **Fix prévu** : Update the comment block above `parse_echo_yaml` to reflect actual status (kept for the test suite as a reference parser, no longer in the hot path). No code change. Removing the function entirely would be a B item (removes 3 tests, design decision). +- **Status v0.5.0** : **CLOSED in v0.2.0** — comment block refreshed. ### A4 — Move `MAX_SAMPLE_COUNT` out of `services.inspector` into a shared constants module - **Origine** : architecture audit, "⚠️ Refactor opportunities", item 2. - **Description** : `services/health.py:11` cross-imports `MAX_SAMPLE_COUNT` from `services/inspector.py`. The audit flags this as a smell that duplicates once DDS adds its own per-tool caps — and DDS has shipped (`peek_dds_samples` in `services/inspector.py:90`). - **Fix prévu** : Create `services/constants.py` hosting `MAX_SAMPLE_COUNT` (and any future per-tool caps). Update imports in `services/inspector.py`, `services/health.py`, and `tests/test_health.py`. Mechanical relocation, no logic change. +- **Status v0.5.0** : **CLOSED in v0.2.0** — `services/constants.py:21` is the canonical home. `services/inspector.py:38` re-exports it for backward-compat with v0.1.x importers. --- -## B — Defer to v0.3+ +## B — Deferred to v0.3+ — refreshed dispositions v0.5.0 + +### Security-side — `B1`–`B5` still DEFER -### Security-side (5 items from "Hardening opportunities", + the 5 items already in "Roadmap v0.3+" carried forward) +The five "Hardening opportunities" bullets remain hosted-context-only or future-adapter-only concerns. No work shipped between v0.2.0 and v0.4.0 since the threat model is still local-trust (`README.md` "Security model"). They live in `docs/product-plan.md §5` as "Audit-driven v0.3 candidates" and stay there until the hosted MCP endpoint (Phase 3 in §7) reopens them. -- **B1 — `TOPICFORGE_ROS2_BIN` allowlist for hosted contexts.** Security audit, "Hardening opportunities" #1. Defer : hosted multi-tenant concern, the v0.2.0 threat model is local-trust per README "Security model" line 239. Where to add : `docs/product-plan.md §5`. -- **B2 — Scrubbed `subprocess.run(env=...)` for hosted contexts.** Security audit, "Hardening opportunities" #2. Defer : same hosted concern. Where : `docs/product-plan.md §5`. -- **B3 — `analyze_bag` workspace-root allowlist sandbox.** Security audit, "Hardening opportunities" #3. Defer : same hosted concern. Where : `docs/product-plan.md §5`. -- **B4 — `_validate_bag_path` Path.resolve traversal rejection.** Security audit, "Hardening opportunities" #4. Defer : couples with B3 (only meaningful with a workspace root). Where : `docs/product-plan.md §5` (bundled with B3). -- **B5 — Stricter `stderr_tail` sanitization for adapters running user-supplied commands.** Security audit, "Hardening opportunities" #5. Defer : not relevant in v0.2.0 (no user-supplied-command adapter exists). Where : `docs/product-plan.md §5`. +- **B1 — `TOPICFORGE_ROS2_BIN` allowlist for hosted contexts.** Security audit, "Hardening opportunities" #1. **DEFER** (no change). Where : `docs/product-plan.md §5`. +- **B2 — Scrubbed `subprocess.run(env=...)` for hosted contexts.** Security audit, "Hardening opportunities" #2. **DEFER** (no change). Where : `docs/product-plan.md §5`. +- **B3 — `analyze_bag` workspace-root allowlist sandbox.** Security audit, "Hardening opportunities" #3. **DEFER** (no change). Where : `docs/product-plan.md §5`. +- **B4 — `_validate_bag_path` Path.resolve traversal rejection.** Security audit, "Hardening opportunities" #4. **DEFER** (no change). Where : `docs/product-plan.md §5` (bundled with B3). +- **B5 — Stricter `stderr_tail` sanitization for adapters running user-supplied commands.** Security audit, "Hardening opportunities" #5. **DEFER** (no change). Where : `docs/product-plan.md §5`. -The security audit's own "Roadmap v0.3+" section (5 items) is already pre-tagged for deferral and will be cross-referenced from `product-plan.md §5`. +The security audit's own "Roadmap v0.3+" section (5 items) remains pre-tagged for the hosted-endpoint phase. -### Architecture-side (7 items from "⚠️ Refactor opportunities", excluding items 2, 6, 8 which are A1/A2/A3/A4) +### Architecture-side — `B6` / `B9` / `B10` CLOSED ; `B7` / `B8` still DEFER -- **B6 — `AdapterName` / `effective_mode` literal split.** Architecture audit, "⚠️ Refactor opportunities" item 1. **Status : already CLOSED in v0.2.0** — `AdapterName` widened to `Literal["mock", "ros2_cli", "cyclone", "rti"]` and `EffectiveMode` extracted as a separate `Literal["mock", "live"]` (`adapters/base.py:25-37`). Not a v0.3+ deferral ; documented here for audit-trail completeness. -- **B7 — Collapse `Mode` / `ResolvedMode` / `AdapterName` into a single tri-mode hierarchy.** Architecture audit item 3. Defer : design decision (which module owns the canonical Literal ?). Where : `docs/product-plan.md §5` "Audit-driven v0.3 candidates". -- **B8 — Tighten `HealthReport.mode` / `requested_mode` from `str` to `Literal`.** Architecture audit item 4. Defer : schema soft-breaking — strict MCP clients validating against v0.2.0 schema would need re-generation. Plan with v0.3 wire-contract review. Where : `docs/product-plan.md §5`. -- **B9 — DDS topic-name regex (allow `::` and DDS separators).** Architecture audit item 5. Defer : depends on the real CycloneDdsAdapter implementation (v0.2.x patch per `mcp-02-spec.md §7`). The v0.2.0 stub raises before any regex check fires, so no immediate breakage. Where : inline `TODO(roadmap, audit-2026-05-14)` in `services/inspector.py` near `_TOPIC_NAME_RE`. -- **B10 — Inspector validation symmetry (`list_topics` vs `get_topic_info`).** Architecture audit item 7. Defer : the asymmetry is defensible per the existing "symmetric gate" docstring ; future DDS tools (`list_participants`) follow the same pattern. Where : inline `TODO(roadmap, audit-2026-05-14)` in `services/inspector.py:list_topics`. +- **B6 — `AdapterName` / `effective_mode` literal split.** Architecture audit, "⚠️ Refactor opportunities" item 1. **Status : CLOSED in v0.2.0** — `AdapterName` widened to `Literal["mock", "ros2_cli", "cyclone", "rti"]` and `EffectiveMode` extracted as a separate `Literal["mock", "live"]` (`adapters/base.py:25-37`). Not a v0.3+ deferral ; documented here for audit-trail completeness. +- **B7 — Collapse `Mode` / `ResolvedMode` / `AdapterName` into a single tri-mode hierarchy.** Architecture audit item 3. **DEFER** (no change). Design decision still pending — which module owns the canonical `RuntimeMode` Literal ? Plan with the v0.6 wire-contract review alongside B8. Where : `docs/product-plan.md §5` "Audit-driven v0.3 candidates". +- **B8 — Tighten `HealthReport.mode` / `requested_mode` from `str` to `Literal`.** Architecture audit item 4. **DEFER** (no change). Schema soft-breaking — strict MCP clients validating against v0.2.0 schema would need re-generation. Plan with v0.6 wire-contract review. Where : `docs/product-plan.md §5`. +- **B9 — DDS topic-name regex (allow `::` and DDS separators).** Architecture audit item 5. **Status : CLOSED in v0.3.0** — `_validate_topic_name_dds` (`services/inspector.py:56`) ships the relaxed validator with the explicit "Resolves audit-2026-05-14 'Refactor opportunities' #5" comment. The strict ROS2 validator stays in place for the 5 ROS2 graph methods. +- **B10 — Inspector validation symmetry (`list_topics` vs `get_topic_info`).** Architecture audit item 7. **Status : CLOSED in v0.5.0 — WONT-FIX by design.** Decision : `list_topics` takes no MCP-level arguments, so an Inspector-side gate has nothing to validate. Peer methods like `get_topic_info` validate ; the asymmetry is structural, not accidental. The inline `TODO(roadmap, audit-2026-05-14)` at `services/inspector.py:76` is retired and replaced with a permanent design note. Reopen only if a future tool variant ships with args that need normalizing in `list_topics`. --- @@ -67,9 +78,12 @@ None. Every item from the two scoped sections falls into A or B. No v0.2.0-philo --- -## Summary +## Summary — refreshed 2026-05-18 (v0.5.0) -- **4 A items** (address now in this sprint) — all docstring / mechanical relocation, zero behavior change, zero risk of test regression. -- **9 B items** (defer to v0.3+) — added to `docs/product-plan.md §5` as a new sub-list "Audit-driven v0.3 candidates (from 2026-05-14 audits)", plus 2 inline `TODO(roadmap, audit-2026-05-14)` markers. +- **4 A items** (originally scoped for v0.2.0) — all SHIPPED. Zero behavior change, zero test regression. +- **9 B items** (originally deferred to v0.3+): + - **6 CLOSED** : A4 + B6 in v0.2.0 ; B9 in v0.3.0 ; B10 in v0.5.0 as WONT-FIX-by-design. (A1, A2, A3 are A-class but listed here for the audit-trail count.) Strictly B-class : **3 CLOSED** (B6, B9, B10). + - **6 DEFER** : B1, B2, B3, B4, B5 (hosted-context security hardening) and B7, B8 (architecture wire-contract decisions). All live in `docs/product-plan.md §5`. - **0 C items**. -- **1 architecture item (B6) already closed by v0.2.0 work** — documented here for completeness, not requeued. + +This document is now load-bearing only for the 6 deferred items. When the hosted MCP endpoint sprint (Phase 3 in `product-plan.md §7`) opens, refresh again — most security-side items will reopen at that point. diff --git a/examples/01-discover-ros2-stack.md b/examples/01-discover-ros2-stack.md new file mode 100644 index 0000000..8f7e9b7 --- /dev/null +++ b/examples/01-discover-ros2-stack.md @@ -0,0 +1,73 @@ +# 01 — Discover the ROS2 stack + +**Scenario.** You're handed a new robotics codebase and want to know what's +on the graph without scrolling through `ros2 topic ...` invocations. +**Tools exercised.** `health_check`, `list_topics`, `get_topic_info`, +`sample_messages`. **Mode.** Mock — no ROS2 required. + +## Setup + +```bash +pip install topicforge +TOPICFORGE_MODE=mock python -m topicforge +``` + +The mock fixture models a small differential robot with LIDAR + RGB +camera. Five ROS2 topics, deterministic across runs. + +## Conversation + +**You:** + +> What runtime is this MCP serving, and what topics are on the graph? + +**Claude calls:** `health_check()` → confirms `mode_effective="mock"`, +adapter `"mock"`. Then `list_topics()` → returns 5 `TopicInfo`: +`/cmd_vel`, `/odom`, `/scan`, `/tf`, `/camera/image_raw` with full +publisher/subscriber counts and message types. + +**You:** + +> Show me the structure of `/scan` and what its messages look like. + +**Claude calls:** `get_topic_info(topic="/scan")` → returns +`message_type="sensor_msgs/LaserScan"`, `publisher_count=1`, +`subscriber_count=2`, plus QoS profile when available. Then +`sample_messages(topic="/scan", count=2)` → returns 2 deterministic +`MessageSample` objects. + +**You:** + +> Summarize the stack for me. + +**Claude responds** with something like : + +> This is a small mobile robot. Three sensor topics +> (`/scan` from a 1-D LIDAR, `/camera/image_raw` from an RGB camera, +> `/tf` for transforms), one odometry feedback (`/odom`), and one +> command input (`/cmd_vel`). All five are mocked from +> deterministic fixtures — `health_check` reports +> `mode_effective="mock"`. No DDS module is active (you'd need +> `TOPICFORGE_DDS_BACKEND` set for that). + +## Going live + +Replace the run command with : + +```bash +source /opt/ros/humble/setup.bash +TOPICFORGE_MODE=live python -m topicforge +``` + +The same four tool calls work against any real ROS2 graph — the wire +shape is identical between mock and live (the `mode_effective` field +on every response is the only behavioral hint). + +## Variants + +- **No publisher on the topic** — `sample_messages` in live mode times + out after 3 seconds and returns an empty `samples` list ; the + `SampleResult.count` field reflects the truth (0). +- **Headerless message types** — `samples[i].timestamp_ns` is `0` + for types without a `Header` (e.g. `std_msgs/String`). See the + `parse_csv_echo` story in CHANGELOG `[0.1.2]`. diff --git a/examples/02-debug-qos-mismatch.md b/examples/02-debug-qos-mismatch.md new file mode 100644 index 0000000..f1a209f --- /dev/null +++ b/examples/02-debug-qos-mismatch.md @@ -0,0 +1,85 @@ +# 02 — Debug a QoS mismatch on the DDS bus + +**Scenario.** A subscriber reports "no data" but `ros2 topic list` shows +the topic and a publisher. Classic DDS QoS incompatibility. **Tools +exercised.** `list_participants`, `detect_qos_mismatches`, +`peek_dds_samples`. **Mode.** Mock — exercises the canonical fixture. + +## Setup + +```bash +pip install topicforge +TOPICFORGE_MODE=mock python -m topicforge +``` + +The mock DDS fixture ships three deterministic participants +(two Cyclone-tagged, one Fast-tagged) on domain `0`, with two topics : +`/dds/well_matched` (compatible QoS) and `/dds/qos_mismatch` +(deliberate Reliability incompatibility — RELIABLE reader vs +BEST_EFFORT writer). + +## Conversation + +**You:** + +> Who is on the DDS bus right now, and is there a topic where readers +> and writers don't agree on QoS? + +**Claude calls:** `list_participants(domain_id=0)` → returns 3 +participants with `vendor`, `hostname`, `guid`, lifecycle fields +(`first_seen_ns`, `status="active"`, `seen_count`). The +multi-vendor mix exercises the OMG-DDS-RTPS protocol guarantee in +fixture form. Then `detect_qos_mismatches(topic=None)` → returns 1 +`MismatchReport` for `/dds/qos_mismatch` with +`incompatible_policies=["Reliability"]` and +`severity="incompatible"`. + +**You:** + +> What policy is wrong, and what should I change? + +**Claude responds** with the canonical fix narrative : + +> The `/dds/qos_mismatch` topic has a reader requesting +> `Reliability: RELIABLE` and a writer offering +> `Reliability: BEST_EFFORT`. By the OMG-DDS-RTPS Request-vs-Offered +> rule, BEST_EFFORT writers cannot satisfy RELIABLE readers — the +> match is rejected at discovery and no samples flow. Two fixes : +> +> 1. Relax the reader to `BEST_EFFORT` (if data loss is acceptable). +> 2. Upgrade the writer to `RELIABLE` (if the data is critical). +> +> The other topic `/dds/well_matched` has no mismatch reported, so +> the same diagnostic loop would not fire there. + +**You:** + +> Peek what's actually flowing on `/dds/well_matched`. + +**Claude calls:** `peek_dds_samples(topic="/dds/well_matched", +count=3)` → returns 3 deterministic samples with full payload. The +mock fixture also includes two user-topic exemplars exercising the +v0.4.0 decode story : `/dds/ddsforge/example` returns +`_decode_status="full"`, `/dds/ddsforge/opaque` returns +`_decode_status="raw"` with a `_raw_bytes_hex` preview. + +## Going live + +```bash +pip install topicforge[dds-cyclone] # or [dds-fast] / [dds] +TOPICFORGE_MODE=live TOPICFORGE_DDS_BACKEND=cyclone python -m topicforge +``` + +The same three tool calls work against any real DDS domain — Cyclone +and Fast both observe every conformant vendor on the wire (RTI +Connext, OpenDDS, CoreDX, Dust DDS, etc.) via the OMG protocol +guarantee. See [`docs/dds-interop-matrix.md`](../docs/dds-interop-matrix.md). + +## Why this is hard without TopicForge + +Diagnosing QoS mismatches manually means correlating `ros2 topic info +-v` against the publisher's `rmw_qos_profile_t` setup in C++ source, +then mentally running the OMG compatibility matrix. TopicForge ships +that matrix as a vendor-neutral pure analyzer +(`adapters/common/qos_analyzer.py`) so the LLM can suggest the fix +without a 20-minute deep-dive. diff --git a/examples/03-analyze-recording.md b/examples/03-analyze-recording.md new file mode 100644 index 0000000..a81f049 --- /dev/null +++ b/examples/03-analyze-recording.md @@ -0,0 +1,81 @@ +# 03 — Post-mortem inspection of a bag recording + +**Scenario.** A field deployment failed at 14:32:17 UTC and the team +saved a `.mcap` of the 5-minute window around the failure. You need +quick answers : duration, message counts, anomaly signatures, then a +deep-dive on a specific topic. **Tools exercised.** `analyze_bag`, +`peek_bag_samples`. **Mode.** Mock — exercises the deterministic bag +fixtures. + +## Setup + +```bash +pip install topicforge[bags] # rosbags is optional ; required for peek_bag_samples +TOPICFORGE_MODE=mock python -m topicforge +``` + +The mock fixture exposes `MOCK_BAG_ANALYSIS` and `MOCK_BAG_SAMPLES` +modeling a 60-second recording of the differential robot from +[`01-discover-ros2-stack.md`](01-discover-ros2-stack.md). Three +deterministic topics with realistic frequencies and one canned +anomaly per topic to exercise the analysis loop. + +## Conversation + +**You:** + +> Summarize the bag at `/tmp/demo.mcap` — how long, how many messages, +> anything suspicious? + +**Claude calls:** `analyze_bag(path="/tmp/demo.mcap")` → returns +`BagAnalysis` with `duration_seconds=60.0`, `message_count=600`, +three `BagTopicStats` entries with per-topic counts and frequencies, +`bag_format="mcap"`, `recording_duration_ns=60_000_000_000`, +`samples_decoded_count=0` (analyze does not decode samples — that's +`peek_bag_samples`'s job), `participants_recorded=[]` (MCAP can +embed participant metadata but the mock fixture doesn't), and a +list of canned `BagAnomaly` records (clock jumps, frequency drift, +etc.). + +**You:** + +> Show me the actual messages on `/scan` around the anomaly. + +**Claude calls:** `peek_bag_samples(path="/tmp/demo.mcap", +topic="/scan", count=5)` → returns 5 decoded samples through the +shared `cdr_decoder`. Each carries the same `_decode_status` +annotation as live `peek_dds_samples` — typically `"full"` for +ROS2-recorded bags with embedded type descriptions. + +**You:** + +> Were there any non-ROS DDS participants recorded in the bag? + +**Claude responds** referencing `BagAnalysis.participants_recorded`. +For mock-mode bags this is empty (typical for `.db3` and `.bag`) ; +MCAP recordings from a multi-vendor DDS deployment may carry +participant records, which TopicForge surfaces verbatim. The same +`ParticipantInfo` schema is shared with `list_participants` so an +LLM consumer reads one envelope across live and post-mortem flows. + +## Going live + +```bash +pip install topicforge[bags] +source /opt/ros/humble/setup.bash # for the analyze_bag CLI fallback +TOPICFORGE_MODE=live python -m topicforge +``` + +`analyze_bag` retains the v0.3.0 `ros2 bag info` text-parse fallback +on `Ros2CliAdapter` when rosbags is absent ; the enriched fields +(`bag_format`, `recording_duration_ns`, …) populate at safe defaults +in that path. `peek_bag_samples` strictly requires `rosbags` — no +silent fallback because sample decoding has no CLI equivalent. + +## Three formats, one API + +`rosbags` (Apache 2.0, pure-Python) reads MCAP, ROS2 `.db3`, and ROS1 +`.bag` recordings through a single `AnyReader` API. TopicForge wraps +that surface in `BagService` so the LLM doesn't care which container +the team used — the `bag_format` field surfaces the detected +container for downstream tooling. diff --git a/examples/04-monitor-topic-frequency.md b/examples/04-monitor-topic-frequency.md new file mode 100644 index 0000000..badf166 --- /dev/null +++ b/examples/04-monitor-topic-frequency.md @@ -0,0 +1,88 @@ +# 04 — Monitor topic frequency and participant lifecycle + +**Scenario.** A robotics integrator complains that a 10 Hz heartbeat +topic "seems jittery". You want concrete numbers — observed +frequency, sequence gaps, latency percentiles — plus visibility on +participants joining and leaving the bus during the observation +window. **Tools exercised.** `topic_metrics`, `participant_events`. +**Mode.** Mock — exercises the deterministic 10 Hz heartbeat fixture. + +## Setup + +```bash +pip install topicforge +TOPICFORGE_MODE=mock python -m topicforge +``` + +The mock observability fixture ships : + +- `/dds/heartbeat_10hz` — 100 samples spaced 100 ms apart with + synthetic 50 ms latency and contiguous sequence numbers 0..99 +- A singleton topic (one sample) for low-traffic edge-case testing +- A cross-domain topic exercising the `domain_id` filter + +## Conversation + +**You:** + +> Tell me the observed frequency, jitter, and any sequence gaps on +> `/dds/heartbeat_10hz` over the last minute. + +**Claude calls:** `topic_metrics(topic="/dds/heartbeat_10hz", +window_seconds=60, domain_id=0)` → returns `TopicMetrics` with +`samples_observed=100`, `frequency_hz_observed≈10.0`, +`frequency_hz_declared=10.0` (from QoS Deadline), +`sequence_gaps_count=0`, `latency_ns_p50≈50_000_000`, +`latency_ns_p95≈50_000_000`, `latency_ns_p99≈50_000_000`, and +boolean availability flags for each conditional metric. + +**You:** + +> Did any participants come or go on the bus during that window? + +**Claude calls:** `participant_events(domain_id=0, +lookback_seconds=60)` → returns a list of `ParticipantEvent` with +`event_type="discovered"` or `"lost"`, `timestamp_ns`, and the full +embedded `ParticipantInfo` (vendor, hostname, GUID, lifecycle +fields). + +**Claude responds** with a synthesis : + +> Over the last 60 seconds I observed 100 samples on +> `/dds/heartbeat_10hz`, exactly matching the declared 10 Hz QoS +> Deadline. Sequence numbers were contiguous (0 gaps), and end-to-end +> latency p50/p95/p99 all sat at ~50 ms — uniform, no jitter. +> Three participants stayed active throughout the window — no +> discovery or loss events fired. The "jittery heartbeat" report +> isn't reproducing here — recommend checking the **complainant's +> subscriber** end (publisher → bus → subscriber is fine ; +> subscriber → application callback might be the bottleneck). + +## Caveats — opportunistic fill + +**`topic_metrics` is opportunistic.** Neither `cyclonedds` nor +`fastdds` 2.6.x Python bindings expose at-sample-receive callbacks, +so the `MetricsBuffer` accumulates ONLY as `peek_dds_samples` is +exercised on the same topic. In a production observation loop you'd +periodically call `peek_dds_samples` to keep the buffer warm. The +tool description carries this caveat to the LLM ; mock mode hides it +(deterministic fixtures pre-populate the buffer). + +**`participant_events` on Cyclone is polling-driven.** Cyclone's +lifecycle log updates only when `list_participants` (or any internal +poll of the `DCPSParticipant` builtin reader) is called. A +participant that joined and left between two tool calls is invisible. +Fast DDS uses native listener callbacks and captures both arrival +and removal natively. + +## Going live + +```bash +pip install topicforge[dds-fast] # listener-callback-driven lifecycle +TOPICFORGE_MODE=live TOPICFORGE_DDS_BACKEND=fast python -m topicforge +``` + +On Fast DDS, `participant_events` captures every `discovered` / +`lost` event natively. On Cyclone, periodic `list_participants` +calls keep the lifecycle buffer warm. Either backend serves +`topic_metrics` identically — the buffer is vendor-neutral. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..4db0b1c --- /dev/null +++ b/examples/README.md @@ -0,0 +1,30 @@ +# TopicForge — examples + +Four end-to-end walkthroughs, each runnable against the **mock adapter** +(no ROS2 / DDS install required). Each example pairs an MCP-client +prompt with the expected tool calls and a short LLM-facing diagnosis. + +| File | Scenario | Tools exercised | +| ---- | -------- | --------------- | +| [`01-discover-ros2-stack.md`](01-discover-ros2-stack.md) | Bring up TopicForge, list the graph, peek a topic | `health_check`, `list_topics`, `get_topic_info`, `sample_messages` | +| [`02-debug-qos-mismatch.md`](02-debug-qos-mismatch.md) | "My subscriber is not receiving" — multi-vendor QoS diagnosis | `list_participants`, `detect_qos_mismatches`, `peek_dds_samples` | +| [`03-analyze-recording.md`](03-analyze-recording.md) | Post-mortem inspection of an MCAP / DB3 / BAG recording | `analyze_bag`, `peek_bag_samples` | +| [`04-monitor-topic-frequency.md`](04-monitor-topic-frequency.md) | Live observability — frequency, sequence gaps, lifecycle events | `topic_metrics`, `participant_events` | + +## How to run any example + +```bash +pip install topicforge +TOPICFORGE_MODE=mock python -m topicforge +# Windows PowerShell: $env:TOPICFORGE_MODE="mock"; python -m topicforge +``` + +Point any MCP client (Claude Desktop / Claude Code / Cursor / Cline) +at this server with `"env": { "TOPICFORGE_MODE": "mock" }` in the +config. The mock adapter exposes all 11 tools against deterministic +fixtures, so every example is reproducible byte-for-byte. + +To run against a real bus, swap `TOPICFORGE_MODE=mock` for +`TOPICFORGE_MODE=live` and (for the DDS examples) set +`TOPICFORGE_DDS_BACKEND=cyclone` or `=fast`. See +[`docs/DDS_QUICKSTART.md`](../docs/DDS_QUICKSTART.md) for backend setup. diff --git a/pyproject.toml b/pyproject.toml index 70326b5..3603234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dependencies = [ "mcp>=1.0.0", diff --git a/src/topicforge/adapters/common/dds_helpers.py b/src/topicforge/adapters/common/dds_helpers.py index 7039185..8b96faa 100644 --- a/src/topicforge/adapters/common/dds_helpers.py +++ b/src/topicforge/adapters/common/dds_helpers.py @@ -98,14 +98,24 @@ def format_guid(raw: bytes | tuple[int, ...] | str | None) -> str: DDS_ONLY_ERROR_MSG = ( - "This adapter serves DDS observability only. Use TOPICFORGE_MODE=live " - "with TOPICFORGE_DDS_BACKEND=mock (the default) for the 5 ROS2 graph " - "and bag tools, or TOPICFORGE_MODE=mock for end-to-end fixtures." + "This adapter serves DDS observability only — it cannot run the ROS2 " + "graph tools (list_topics, get_topic_info, sample_messages, analyze_bag, " + "peek_bag_samples). To get both surfaces in one process: install ROS2 " + "and source the workspace so `ros2` is on PATH, then re-run with " + "TOPICFORGE_MODE=live — the v0.4.0 CompositeAdapter routes ROS2 tools to " + "the CLI and DDS tools to your TOPICFORGE_DDS_BACKEND automatically. " + "For offline development use TOPICFORGE_MODE=mock." ) """Standard message raised by DDS adapters when asked for ROS2 introspection. Both `CycloneDdsAdapter` and `FastDdsAdapter` raise -`AdapterError(DDS_ONLY_ERROR_MSG)` on the 4 ROS2 methods of the +`AdapterError(DDS_ONLY_ERROR_MSG)` on the 5 ROS2 methods of the `MiddlewareAdapter` protocol. The single message keeps the user-facing -remediation text consistent across vendors. +remediation text consistent across vendors. Lists every tool affected so +the LLM caller can suggest exactly the right next action. + +Test contract: must contain the substrings `"DDS observability only"`, +`"TOPICFORGE_DDS_BACKEND"`, `"TOPICFORGE_MODE"` (pinned by +`tests/test_dds_helpers.py` and the cross-vendor / cyclone / fast adapter +test suites). """ diff --git a/src/topicforge/adapters/dds_cyclone/adapter.py b/src/topicforge/adapters/dds_cyclone/adapter.py index 434efe9..3edd302 100644 --- a/src/topicforge/adapters/dds_cyclone/adapter.py +++ b/src/topicforge/adapters/dds_cyclone/adapter.py @@ -318,7 +318,12 @@ def list_participants(self, domain_id: int = 0) -> list[ParticipantInfo]: reader = BuiltinDataReader(self._dp, BuiltinTopicDcpsParticipant) samples = list(reader.take_iter(timeout=duration(seconds=_DISCOVERY_TIMEOUT_SEC))) except Exception as exc: - raise AdapterError(f"cyclone participant discovery failed: {exc}") from exc + raise AdapterError( + f"CycloneDDS participant discovery failed on domain {self._domain_id} " + f"({type(exc).__name__}: {exc}). Common causes: DDS domain mismatch, " + f"firewall blocking RTPS multicast, or CYCLONEDDS_URI pointing at an " + f"unreadable config." + ) from exc observed_guids: set[str] = set() for sample in samples[:_MAX_PARTICIPANTS]: @@ -352,7 +357,10 @@ def detect_qos_mismatches(self, topic: str | None = None) -> list[MismatchReport :_MAX_ENDPOINTS ] except Exception as exc: - raise AdapterError(f"cyclone endpoint discovery failed: {exc}") from exc + raise AdapterError( + f"CycloneDDS endpoint discovery failed on domain {self._domain_id} " + f"({type(exc).__name__}: {exc})." + ) from exc by_topic: dict[str, tuple[list[Any], list[Any]]] = {} for sample in subs: @@ -425,7 +433,9 @@ def _peek_builtin(self, topic: str, count: int) -> SampleResult: :count ] except Exception as exc: - raise AdapterError(f"cyclone peek failed: {exc}") from exc + raise AdapterError( + f"CycloneDDS sample peek failed on topic {topic!r} ({type(exc).__name__}: {exc})." + ) from exc import time diff --git a/src/topicforge/adapters/dds_fast/adapter.py b/src/topicforge/adapters/dds_fast/adapter.py index dbd50d3..1213a58 100644 --- a/src/topicforge/adapters/dds_fast/adapter.py +++ b/src/topicforge/adapters/dds_fast/adapter.py @@ -198,13 +198,20 @@ def __init__( mask = fastdds.StatusMask.all() self._participant = factory.create_participant(domain_id, qos, self._listener, mask) if self._participant is None: - raise AdapterError("DomainParticipantFactory.create_participant returned None") + raise AdapterError( + f"Fast DDS DomainParticipant creation returned None on domain " + f"{domain_id}. Likely an ABI mismatch between the `fastdds` Python " + f"binding and the installed Fast DDS core library — pin " + f"`fastdds>=2.6.1,<3` and reinstall, or check the FastDDS_DEFAULT_PROFILES_FILE " + f"env var if you set one." + ) self._factory = factory except AdapterError: raise except Exception as exc: raise AdapterError( - f"Failed to create Fast DDS DomainParticipant on domain {domain_id}: {exc}" + f"Failed to create Fast DDS DomainParticipant on domain {domain_id} " + f"({type(exc).__name__}: {exc})." ) from exc # Bounded warm-up — discovery callbacks fire asynchronously after # the participant joins. diff --git a/src/topicforge/services/bag_service.py b/src/topicforge/services/bag_service.py index b3044cf..703dd9c 100644 --- a/src/topicforge/services/bag_service.py +++ b/src/topicforge/services/bag_service.py @@ -48,7 +48,10 @@ _MAX_SAMPLE_COUNT = 50 _ROSBAGS_REQUIRED_MSG = ( - "Bag sample peek requires the `rosbags` library. Install via `pip install topicforge[bags]`." + "Bag analysis with full sample decode requires the `rosbags` library. " + "Install via `pip install topicforge[bags]` and retry. " + "`analyze_bag` may still fall back to the v0.3.0 `ros2 bag info` text-parse " + "path via the live ROS2 CLI adapter ; `peek_bag_samples` has no fallback." ) @@ -115,7 +118,9 @@ def analyze(self, path: str, *, mode_effective: str = "live") -> BagAnalysis: except AdapterError: raise except Exception as exc: # pragma: no cover — defensive - raise AdapterError(f"failed to open bag {path!r}: {exc}") from exc + raise AdapterError( + f"failed to open bag {path!r} ({type(exc).__name__}: {exc})" + ) from exc return BagAnalysis( path=str(path), @@ -155,7 +160,9 @@ def peek_samples( except AdapterError: raise except Exception as exc: # pragma: no cover — defensive - raise AdapterError(f"failed to peek samples from {path!r}: {exc}") from exc + raise AdapterError( + f"failed to peek samples from {path!r} ({type(exc).__name__}: {exc})" + ) from exc return SampleResult( topic=topic, diff --git a/src/topicforge/services/inspector.py b/src/topicforge/services/inspector.py index 532313e..9788b57 100644 --- a/src/topicforge/services/inspector.py +++ b/src/topicforge/services/inspector.py @@ -73,11 +73,12 @@ def backend_name(self) -> AdapterName: return self._adapter.name def list_topics(self) -> list[TopicInfo]: - # TODO(roadmap, audit-2026-05-14): validation symmetry — list_topics - # is a pass-through with no input to validate, while peer methods - # like get_topic_info validate. The "symmetric gate" docstring - # justifies it today, but revisit if new tools land that take args - # this method does not. See architecture audit "Refactor" #7. + # Validation symmetry note (audit-2026-05-14 "Refactor" #7, resolved + # WONT-FIX in v0.5.0): list_topics takes no MCP-level arguments, so + # an Inspector-side gate would have nothing to validate. Peer methods + # like get_topic_info do validate ; the asymmetry is structural, not + # accidental. Reopened only if a future tool variant ships with args + # that need normalizing here. return self._adapter.list_topics() def get_topic_info(self, topic: str) -> TopicInfo: diff --git a/tests/test_bag_service.py b/tests/test_bag_service.py index f66f2fb..873b1b4 100644 --- a/tests/test_bag_service.py +++ b/tests/test_bag_service.py @@ -102,6 +102,47 @@ def test_bag_service_peek_rejects_negative_count( svc.peek_samples("/tmp/whatever.mcap", "/topic", -1) +def test_bag_service_analyze_surfaces_exception_type_in_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: pytest.TempPathFactory +) -> None: + """v0.5.0 polish: wrap the rosbags-side exception type into the message so + the LLM caller can act on `PermissionError` / `IsADirectoryError` etc. without + re-reading the traceback.""" + from topicforge.services import bag_service + + fake_bag = tmp_path / "x.mcap" # type: ignore[attr-defined] + fake_bag.write_bytes(b"") + + def _boom(_path): + raise RuntimeError("synthetic open failure") + + monkeypatch.setattr(bag_service, "is_rosbags_available", lambda: True) + monkeypatch.setattr(bag_service, "_read_with_rosbags", _boom) + svc = BagService() + with pytest.raises(AdapterError, match=r"RuntimeError.*synthetic"): + svc.analyze(str(fake_bag)) + + +def test_bag_service_peek_surfaces_exception_type_in_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: pytest.TempPathFactory +) -> None: + """Same as analyze: exception class name + str(exc) must appear in the message + so the caller can pivot without inspecting `__cause__`.""" + from topicforge.services import bag_service + + fake_bag = tmp_path / "x.mcap" # type: ignore[attr-defined] + fake_bag.write_bytes(b"") + + def _boom(_path, _topic, _count): + raise OSError("synthetic peek failure") + + monkeypatch.setattr(bag_service, "is_rosbags_available", lambda: True) + monkeypatch.setattr(bag_service, "_peek_with_rosbags", _boom) + svc = BagService() + with pytest.raises(AdapterError, match=r"OSError.*synthetic"): + svc.peek_samples(str(fake_bag), "/topic", 5) + + # -------------------------------------------------------------------------- # requires_rosbags — auto-skipped without the library # -------------------------------------------------------------------------- diff --git a/tests/test_dds_helpers.py b/tests/test_dds_helpers.py index a8906d9..3ec74df 100644 --- a/tests/test_dds_helpers.py +++ b/tests/test_dds_helpers.py @@ -115,3 +115,25 @@ def test_dds_only_error_msg_mentions_remediation() -> None: path so an LLM client can take action without re-reading docs.""" assert "TOPICFORGE_DDS_BACKEND" in DDS_ONLY_ERROR_MSG assert "TOPICFORGE_MODE" in DDS_ONLY_ERROR_MSG + + +def test_dds_only_error_msg_preserves_substring_match_token() -> None: + """`tests/test_cyclone_adapter.py` and friends use `match="DDS observability only"` + via `pytest.raises`. The substring must survive any future re-wording so those + test files don't quietly regress.""" + assert "DDS observability only" in DDS_ONLY_ERROR_MSG + + +def test_dds_only_error_msg_mentions_composite_remediation() -> None: + """v0.5.0 polish: the message must name the v0.4.0 CompositeAdapter as the + canonical remediation for the dual-surface workflow, so an LLM caller can + suggest the right action immediately.""" + assert "CompositeAdapter" in DDS_ONLY_ERROR_MSG + + +def test_dds_only_error_msg_lists_affected_tools() -> None: + """The message names every ROS2-side tool a DDS-only adapter cannot serve, + so the LLM caller does not need to introspect the protocol to know what is + blocked.""" + for tool in ("list_topics", "get_topic_info", "sample_messages", "analyze_bag"): + assert tool in DDS_ONLY_ERROR_MSG, f"missing tool name in error message: {tool!r}"