日本語: README.ja.md
A system that records, monitors, validates, and converts ROS 2 robot data. The canonical recording format is MCAP, and live video, live metrics, and post-hoc validation are all organized around this "source of truth."
Status: All 7 services (frontend included) plus the UI-driven acceptance suite (
make test-e2e) are implemented. The architecture below is based on thefig_const/diagrams.
1 folder = 1 container. Responsibilities are split across processes so that heavy work (decoding, validation) never bleeds into recording and monitoring.
flowchart TB
ROBOT["ROS 2 Robot / Sim"] --> TOPICS(["ROS 2 Topics (DDS)"])
subgraph live["live path — ROS 2 containers (rclpy)"]
REC["rosbag2_recorder<br/>selected topics → MCAP"]
MON["topic_monitor<br/>Hz / latency / loss / bandwidth<br/>(never decodes)"]
PROBE["topic_probe<br/>numeric-field plots<br/>(decoding is isolated here)"]
WEB["webrtc_streamer<br/>low-latency preview"]
end
subgraph post["post-recording path"]
ORC["api_orchestrator<br/>job / state / config hub"]
DR["dora_runner<br/>validation & conversion<br/>(bundled bagflow + dora)"]
end
FE["frontend<br/>Vite + React + TS"]
subgraph store["capture store (/data)"]
MCAP[("objects/<capture_id>/<br/>*.mcap + object_manifest.json<br/>+ record.json = the source of truth")]
LEDGER[("lifecycle.jsonl<br/>ledger of discards, deletes, archives")]
DB[("kairos.db<br/>index; rebuildable from the sidecars")]
VIEWS[("views/ · .trash/<br/>dataset symlink tree / deletion staging")]
OUT[("report/<pipeline>/<capture_id>/<br/>reports")]
end
TOPICS --> REC & MON & PROBE & WEB
REC --> MCAP
MCAP --> DR --> OUT
FE <-->|"REST / SSE / WebRTC"| ORC
ORC <--> REC & MON & PROBE & WEB
ORC <-->|"POST /jobs"| DR
ORC -->|"indexes, deletes, regenerates views"| DB
ORC --> MCAP & LEDGER & VIEWS
MCAP -.->|"rebuild at startup"| DB
WEB -.->|"media goes direct"| FE
Post-recording validation is contained entirely inside the dora_runner container (a bundled bagflow flow run on its own dora coordinator; see the dora_runner spec):
flowchart LR
J["POST /api/v1/jobs"] --> API["dora_runner API"]
API --> PIPE["bagflow_pipeline<br/>materialize · timeout · cleanup"]
FLOW[/"flow definition (YAML)<br/>bundled or config/<robot>/flows/"/] --> PIPE
PIPE -->|"bagflow run"| CO["dora coordinator/daemon<br/>127.0.0.1:6112 loopback"]
CO --> NODES["check nodes (Rust)<br/>topic-presence / topic-rate<br/>decode / blur / brightness<br/>freeze / stamp-gap"]
NODES --> RPT["report.json"] --> SUM["summary.json<br/>pass / fail"]
| Service | Role |
|---|---|
| rosbag2_recorder | Records selected ROS 2 topics to MCAP. The single source of truth. |
| topic_monitor | Lightweight, non-intrusive live health metrics (Hz / latency / gaps / loss / bandwidth). Does not decode payloads. |
| topic_probe | A generic probe that live-plots numeric fields of selected topics. Decoding is isolated to this service so it doesn't affect recording or monitoring. |
| webrtc_streamer | Low-latency camera preview (ROS 2 image → browser). Not a recording path. |
| api_orchestrator | The single API hub. Handles job lifecycle, state, configuration, and result aggregation. |
| dora_runner | Post-recording validation & conversion pipeline. Validation runs as a bundled bagflow flow on real dora. Enabled: fast_validation / full_validation / loss_report / video_check / signal_report. |
| frontend | A backend-driven Web UI (UI labels in English). Role tabs (Console v2): Collect / Review / Datasets / Validation / Monitor / Settings. |
For the detailed spec of each service, see docs/specs/en/. How recorded data is laid out and kept durable (objects/<capture_id>, sidecars, deletion, rebuilding the DB) is collected in capture_store as the cross-service foundation. Based on fig_const/, this is the canonical design (unspecified items fixed as recommended designs; no authentication).
- Docker / Docker Compose (to start all services together).
- Place sample rosbags (MCAP) under
data/for local verification (e.g.data/airoa-moma-mcap/<episode>/).data/and*.mcapare gitignored (not committed). - Only when running unit tests directly: uv (Python) and Node.js + npm (frontend).
make build # build the images (first time and after code changes; needs network)
make up # start (detached). Robot selected via ROBOT (default airoa_hsr)
# or with plain docker compose (compose files live under compose/;
# --project-directory pins relative paths to the repo root):
cp .env.example .env # edit as needed
docker compose --project-directory . -f compose/compose.yaml build
docker compose --project-directory . -f compose/compose.yaml up
make updoes not build (it only starts). Building needs the network even when nothing changed, so anupthat always built could not bring the stack up in the field with no network. To apply code changes usemake rebuild <service>; to refresh the upstream base images too,make build-pull. For a machine with no images at all, see Running on an offline machine.
All services start with host networking. The ROS 2 services (recorder / monitor / streamer)
share the host DDS graph (ROS_DOMAIN_ID=0), and the pure-Python services (orchestrator / dora_runner)
and the frontend reach each other at localhost:<port> (no authentication; LAN assumed).
All settings live in a single .env file. There are two templates for it, so
copy the one that matches how you plan to use kairos. You do not need to read the whole thing.
| How you use it | Template to copy | The first line you touch |
|---|---|---|
| ① Run everything on one PC — the normal case, and how you try the sample bag | .env.example |
Works almost as-is. Change ROBOT= only when using a different robot |
| ② Record from a separate "recording PC" — for people who don't want to load the robot itself | .env.split.example |
Only ROBOT_IP= (the robot's IP address) |
When in doubt, pick ①. Get it running on one PC first, then consider ② when you need it. In both cases, you edit the
.envyou copied (not the*.exampletemplate)..envis not committed to Git (it is.gitignored).
① Single-PC (.env.example) — most people use this.
cp .env.example .env # just copy it; it runs with almost no edits
make up- To just try the sample bag (HSR), no edits are needed (the default
ROBOT=airoa_hsrmatches the sample). - Only when using a different robot, change
ROBOT=in.envto that robot's name (put its config set underconfig/<robot>/; see "Using additional robots" below). - (Advanced) Only when the robot uses Cyclone DDS, change
RMW_IMPLEMENTATION=rmw_cyclonedds_cpp. The other items (port numbers, etc.) can normally stay as they are.
② Separate recording PC (.env.split.example) — for recording without loading the robot.
# On the "recording PC":
cp .env.split.example .env
# Open .env and set ROBOT_IP to the robot's LAN IP (basically the only line you touch)
make recording-up
# ※ On the robot, run `make robot-up` separately (the services that touch the robot — recording,
# monitoring, etc. — run on the robot)- The only line you really edit is
ROBOT_IP(the other destinations reference it automatically). - For why it is split across two machines and the caveats (time sync, permissions, video reachability, etc.), see Deployment topology.
A full reference for every .env key is in the config spec (day to day, the above is enough).
To avoid typing long commands every time, a Makefile is provided at the root. Just make prints the
full list of targets. Service names are positional (multiple allowed). A robot's config is
selected with a single ROBOT (default airoa_hsr); make resolves config/<robot>/ (committed) /
config/local/<robot>/ (gitignored) and passes the paths to each service (avoiding the stale path in .env).
| Command | What it does |
|---|---|
make up / make down / make ps |
Start the stack (does not build) / stop & remove / status |
make build monitor / make build |
Build a service (positional for one / no arg or all for all) |
make rebuild frontend |
Build + force re-create (apply code changes) — the "refresh the container" command |
make build-pull |
Build pulling fresh upstream base images (needs network) |
make images-save / make images-load |
Write the images to one file / load them (carrying them to an offline machine) |
make restart monitor orchestrator |
Restart services |
make logs streamer |
Follow logs |
make config-reload / make config-show |
Apply config/*.yaml edits (restart monitor+orchestrator) / show current config |
make rosbag / make rosbag-loop / make table |
Sample bag single playback / loop playback / Hz table for all topics |
make load |
Load overview: CPU (per-core and per-machine) / measured NIC throughput + link utilization / measured DDS bandwidth / data disk free |
make smoke / make smoke-record |
End-to-end check (PASS/FAIL) / with record start/stop |
make test / make test-py / make test-fe / make lint / make fmt |
Test, lint, format |
make test-e2e |
Acceptance tests from the UI (real browser + real stack + bag replay). Does not build images — run make build first |
To use a different robot, switch ROBOT like make up ROBOT=<robot> (make resolves config/<robot>/
(committed) / config/local/<robot>/ (gitignored) and passes them to each service). For a different bag,
override like make rosbag BAG=/data/<robot>/<run>.
A build uses the network even when nothing changed (BuildKit resolves the base images and the
Dockerfile frontend against the registry). That is why make up only starts: on a machine that
already has the images, bringing the stack up touches the network not at all.
For a machine with no images at all (a fresh robot, a field PC), carry them over as a file instead
of making it build. make up checks — using local information only — that the images it needs are
present, and stops naming the missing ones if they are not (left to compose, a missing image means
either a build or a pull, so offline it just hangs on the network with no useful message).
Images alone are not enough. The repository make and compose read has to travel, and so do the
gitignored .env, config/local/<robot>/ and deploy/msgs_overlay/<robot>/ (git clone itself needs
the network). rsync of the directory carries the gitignored files along with it, so in practice
this is three steps: build the archive, rsync the tree, load and start.
# 1. where there IS network (the image list is derived from compose, so it cannot drift)
make images-save # all services + the replay/inspection harness
make robot-images-save # only the robot-edge 4 (split deployment)
make recording-images-save # only the recording-host 3 (split deployment)
# 2. carry the repository (excludes are mandatory — a plain `scp -r` drags data/ along, tens of GB)
rsync -av \
--exclude='/data/*' \
--exclude='.venv/' \
--exclude='node_modules/' \
--exclude='/deploy/msgs_overlay/*/build/' \
--exclude='/deploy/msgs_overlay/*/log/' \
--exclude='/backups/' --exclude='*.tar.gz' \
~/kairos/ <user>@<host>:~/kairos/
scp kairos-images.tar.gz <user>@<host>:~/
# 3. on that machine
make images-load IMAGES_FILE=~/kairos-images.tar.gz
make up # or make robot-up
make smoke # check it works (the harness travelled too)Why each exclusion, with measured sizes (from this setup — yours will differ):
| What | Size | Why excluded / needed |
|---|---|---|
data/ |
20 GB | Recordings and sample bags. Empty is fine on site |
8× .venv + node_modules |
~950 MB | Host-side dev only; the images carry their own |
overlay build/ + log/ |
65 MB | colcon intermediates — only install/ is needed |
| actually transferred | 40 MB (incl. .git, 9,337 files) |
--exclude='/data/*' rather than /data/ is deliberate: the data/ directory itself must exist
and be empty. Without it Docker creates the ./data:/data bind mount root-owned, and the orchestrator
(which runs non-root) cannot write to it.
A dry run confirms this rsync really carries .env, config/local/<robot>/ and
deploy/msgs_overlay/<robot>/install/. If install/ has not been built, run make msgs-build on the
target machine (just colcon build inside the local recorder image — no network). For a split
deployment, fix *_HOST / ROBOT_IP in .env to the real robot's IP on site.
Change the destination with IMAGES_FILE=. Measured: the 4 robot-edge images → 384 MB in about
35 s; all services plus the harness (8 images) → 562 MB (shared layers are stored once, so the
count matters less than it looks). make images-save deliberately includes the replay/inspection
harness (the image behind make smoke / make rosbag / make table — easy to miss because it is a
separate compose project): the tools you reach for to work out why nothing is coming out should not
themselves demand a build on the machine where you have no network.
Architecture matters: images are per-arch. One built on amd64 will not run on an arm64 robot — build there while it still has network, or use
docker buildx build --platform linux/arm64.
To refresh the upstream base images (ros / python / node), run make build-pull where there is
network and redo step 1.
Steps to add a new robot. A robot with only standard message types can skip the overlay parts of 1–3.
- Prepare config: copy
config/template/toconfig/local/<robot>/and edit it (the four aspectsrecording/stream/validation/validators/; at minimum setdefault_topicsinrecording/default.yamlto the robot's actual topics). Details:config/. - (Custom-type robots only) build the message overlay: without the typesupport of the robot's
non-standard message packages (e.g.
<robot>_msgs), the recorder / monitor / bag playback all silently drop those topics (only standard types flow). If the bag uses several custom packages, prepare all of them (any one missing drops that package's topics). Place the vendor's msg sources underdeploy/msgs_overlay/<robot>/src/<pkg>/, then build (procedure:deploy/msgs_overlay/):make msgs-build MSGS_OVERLAY_DIR=./deploy/msgs_overlay/<robot>
- Start up (pass the overlay;
MSGS_OVERLAY_DIRis unnecessary when there are no custom types):make up ROBOT=<robot> MSGS_OVERLAY_DIR=./deploy/msgs_overlay/<robot>
- Bag playback also needs the overlay:
ros2 bag playneeds typesupport to publish custom types, so pass the same overlay to the player:MSGS_OVERLAY_DIR=./deploy/msgs_overlay/<robot> BAG=/data/<robot>/<run> \ docker compose -f deploy/test/compose.yaml run --rm rosbag_player
- Smoke:
smoke.shdefaults to the bundled HSR bag, so for an added robot specify the bag and overlay explicitly:Note thatenv BAG=/data/<robot>/<run> MSGS_OVERLAY_DIR=./deploy/msgs_overlay/<robot> bash deploy/test/smoke.sh
make table(topic_table) does not load the overlay, so an added robot's custom-type Hz is not shown. Use the monitor'sGET /metrics, or theros2 bag infoprinted at playback.
| Service | Port | Examples |
|---|---|---|
| api_orchestrator | 8000 | GET /api/v1/config / POST /api/v1/record/start / GET /api/v1/events (SSE) / POST /api/v1/jobs |
| topic_monitor | 8001 | GET /metrics / GET /topics / GET /metrics/stream (SSE) / GET /alerts |
| webrtc_streamer | 8002 | POST /stream/start / POST /stream/offer |
| topic_probe | 8003 | GET /topics / GET /fields / GET /stream (SSE) |
| rosbag2_recorder | 8010 | POST /record/start / POST /record/stop / GET /record/status |
| dora_runner | 8020 | POST /jobs / GET /jobs/{id}/result / POST /validation/templates/generate |
The frontend is served by nginx (default 8080). On startup the UI fetches GET /api/v1/config and
renders its schemas and runtime settings backend-driven (the tabs are the fixed Console v2 role tabs:
Collect / Review / Datasets / Validation / Monitor / Settings).
- Stream topics: connect a real robot/simulator, or replay a sample bag.
# "see" the flowing topics (periodically show every topic's Hz/bandwidth/count) docker compose -f deploy/test/compose.yaml run --rm topic_table # replay a sample bag onto the ROS 2 graph (separate terminal; single playback) docker compose -f deploy/test/compose.yaml run --rm rosbag_player # loop playback (keep streaming continuously) LOOP=--loop docker compose -f deploy/test/compose.yaml run --rm rosbag_player # specify a different bag BAG=/data/airoa-moma-mcap/000730 docker compose -f deploy/test/compose.yaml run --rm rosbag_player
- Record: start from the UI (Collect tab) or
POST /api/v1/record/start {"topics":"all"}→ an MCAP is created under/data/objects/<capture_id>/(stop withPOST /api/v1/record/stop). Thecapture_idis a UUIDv7 issued by the recorder, and paths, the API, and the DB are all keyed by it (run_idis the display name). If you fill in the header's OP chip (operator) and Collect's Task, that content plus the topics, count, and start/end are saved toobject_manifest.jsonin the same directory as the MCAP, and are also shown in the Review tab. A recording can be deleted from the Review tab (two steps: Exclude → Discard / Delete; the deletion goes through.trash, and a tombstone row stays in the catalog, so "where did it go?" can still be answered later). The recorderchmod 0777s its output, so it can also be deleted from the host side (outside the container) without sudo — but that does not count as a deletion. A copy that disappears outside kairos shows up as amissing_unmanagedwarning (nothing vanishes silently). - Monitor / preview: live health (Hz / gaps / bandwidth) via
GET /metrics, WebRTC camera preview via/stream. In the UI, the Collect tab owns the camera previews and recording controls, and the Monitor tab owns topic health (always shows every topic on the graph, overlaying live Hz on the monitored ones). The sample bag's Hz shows up with the defaultROBOT=airoa_hsr(which topics to record/monitor is defined per robot inconfig/; reflected as a pre-selection in the Monitor tab's Rec checks). - Post-recording validation & processing (via
POST /api/v1/jobs {capture_id, pipeline}, throughdora_runner):fast_validation— validates the presence/absence of required topics →pass/failin/data/report/fast_validation/<capture_id>/summary.json.loss_report— per-topic loss estimation (the Review tab's "Run loss report").video_check— mp4 preview of camera topics (Review tab; play viaGET /api/v1/files/...).
- Organizing datasets (Datasets tab): add recordings to a dataset or take them out
(
POST /api/v1/datasets/{id}/members). A dataset is a set in the DB, and not one byte of the recording itself moves, so re-adding a recording or having it belong to several datasets is free. A human-navigable symlink tree is generated atdata/views/<operator>/<task>/<dataset>/<NNN>.
- Unit tests:
make test(= each Python serviceuv run --extra test pytest+ frontendnpm run build && npm test && npm run lint). - Acceptance tests (from the UI, against a real stack):
make test-e2e. It brings up a real stack on dedicated ports and a dedicated data dir and drives the frontend in a real browser (Playwright) against a real bag on loop playback. 5 scenarios = record → digest completes / Review save and conflict rejection / Discard and the ledger tombstone / deletingkairos.dband recovering /rm -rf→ SUSPECT → Repair. It coexists with a developer'smake up(different ports, different data dir).make test-e2edoes not build images (the same rule asmake up). After changing code, runmake buildfirst — forget it and you get a green run against the stale code inside the containers. - Smoke test (prints PASS/FAIL): after the stack is up,
make smoke(=bash deploy/test/smoke.sh). It validates health →GET /api/v1/configdefault_topics→ topic discovery → the monitor's live metrics, in order, and prints the result (make smoke-recordalso runs record start/stop). This is the entry point for resolving "I tested it but nothing comes out." - Playback with visualization:
make table(periodically show Hz/bandwidth for all topics) andmake rosbag/make rosbag-loop(playback).
For detailed commands and verified recipes, see "ビルド / テスト / 実行コマンド" in AGENTS.md (Japanese).
Versioning follows SemVer. The current version is the root
VERSION file (single source of truth); the history is in
CHANGELOG.md.
- CI (
.github/workflows/) gates every push / PR todevelopandmain: Python unit tests (shared lib + all six Python services), the frontend build/test/lint, Ruff lint + format, anddocker compose configvalidation. The recorder's realros2 bag recordround-trip runs in the separate ROS integration workflow (it needs the ROS 2 toolchain). - Reproducible images: each service installs its dependencies from the
committed
uv.lock(uv sync --frozen, no>=re-resolution), and base images are pinned by patch tag + digest.make build/make uptag the imageskairos-*:$(cat VERSION)(via the exportedKAIROS_VERSION); a baredocker compose buildfalls back to:dev.
To cut a release:
- Bump
VERSION(e.g.0.1.0→0.2.0). - In
CHANGELOG.md, move the Unreleased entries under a new## [x.y.z] - <date>heading and start a fresh empty Unreleased section. - Commit, then tag and push:
git tag -a vX.Y.Z -m "kairos vX.Y.Z" && git push --tags. make buildthen produces thekairos-*:X.Y.Zimages for that tag.
Japanese is the source of truth. Edit the Japanese files (*.ja.md), and regenerate the English
versions (*.md) by hand to match the Japanese changes. Do not author content in the English versions directly.
The coding-agent instructions — AGENTS.md and CLAUDE.md — are an exception:
they are Japanese only and have no English mirror.