cyclo-manager 0.2.0.dev11__tar.gz → 0.2.0.dev13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. cyclo_manager-0.2.0.dev13/PKG-INFO +275 -0
  2. cyclo_manager-0.2.0.dev13/README.md +261 -0
  3. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_host_agent/models.py +8 -0
  4. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_host_agent/routers/repos.py +56 -1
  5. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_host_agent/routers/update.py +4 -0
  6. cyclo_manager-0.2.0.dev13/cyclo_manager.egg-info/PKG-INFO +275 -0
  7. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager_cli/cli.py +6 -12
  8. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/pyproject.toml +1 -1
  9. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/setup.cfg +1 -1
  10. cyclo_manager-0.2.0.dev11/PKG-INFO +0 -141
  11. cyclo_manager-0.2.0.dev11/README.md +0 -127
  12. cyclo_manager-0.2.0.dev11/cyclo_manager.egg-info/PKG-INFO +0 -141
  13. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_host_agent/__init__.py +0 -0
  14. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_host_agent/main.py +0 -0
  15. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_host_agent/routers/__init__.py +0 -0
  16. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager.egg-info/SOURCES.txt +0 -0
  17. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  18. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager.egg-info/entry_points.txt +0 -0
  19. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager.egg-info/requires.txt +0 -0
  20. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager.egg-info/top_level.txt +0 -0
  21. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager_cli/__init__.py +0 -0
  22. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager_cli/config/config.yml +0 -0
  23. {cyclo_manager-0.2.0.dev11 → cyclo_manager-0.2.0.dev13}/cyclo_manager_cli/docker/docker-compose.yml +0 -0
@@ -0,0 +1,275 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyclo-manager
3
+ Version: 0.2.0.dev13
4
+ Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: fastapi>=0.100.0
9
+ Requires-Dist: uvicorn[standard]>=0.23.0
10
+ Requires-Dist: psutil>=5.9.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest; extra == "dev"
13
+ Requires-Dist: ruff; extra == "dev"
14
+
15
+ # cyclo_manager CLI
16
+
17
+ **PyPI package:** `cyclo-manager`
18
+ **Console commands:** `cyclo_manager` (underscore), `cyclo-manager` (hyphen)
19
+ **Host agent:** `cyclo_host_agent` (installed by the package, run via systemd)
20
+
21
+ This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It:
22
+
23
+ 1. Runs **`docker compose`** against a **bundled** [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml) to start the **API** and **web UI** containers.
24
+ 2. Installs and maintains **`cyclo_host_agent`** as a **systemd** service for host-level operations (git repo updates, `cyclo_manager` package update from the UI).
25
+ 3. Can **`pip install -U`** itself and bring the Docker stack back up.
26
+
27
+ Implementation: [`cyclo_manager_cli/cli.py`](cyclo_manager_cli/cli.py), [`cyclo_host_agent/`](cyclo_host_agent/).
28
+
29
+ For the full monorepo (API source, UI source, dev compose), see the [repository root README](../README.md).
30
+
31
+ ---
32
+
33
+ ## Table of contents
34
+
35
+ - [Prerequisites](#prerequisites)
36
+ - [Install](#install)
37
+ - [Commands](#commands)
38
+ - [What `cyclo_manager up` does](#what-cyclo_manager-up-does)
39
+ - [Host agent (`cyclo_host_agent`)](#host-agent-cyclo_host_agent)
40
+ - [Compose services](#compose-services)
41
+ - [Configuration](#configuration)
42
+ - [Agent sockets on the host](#agent-sockets-on-the-host)
43
+ - [Environment variables](#environment-variables)
44
+ - [URLs (packaged stack)](#urls-packaged-stack)
45
+ - [Custom config and development](#custom-config-and-development)
46
+ - [Dependencies](#dependencies)
47
+ - [License](#license)
48
+
49
+ ---
50
+
51
+ ## Prerequisites
52
+
53
+ - **Docker** with **Compose v2** (`docker compose` — the legacy `docker-compose` binary alone is not enough)
54
+ - **Python 3.10+**
55
+ - **`sudo`** — required to install the host agent systemd unit, socket directory permissions, and sudoers rules
56
+ - For **`cyclo_manager update`:** **`pip`** or **`pip3`** on `PATH`
57
+ - **Do not run as root** — the host agent runs as the invoking user (or `SUDO_USER` when using `sudo cyclo_manager up`). Running as root is rejected.
58
+ - **Agent sockets** on the host under `/var/run/robotis/agent_sockets/` (robot containers and host agent). The bundled Compose file bind-mounts this tree into the API container as `/agents/`.
59
+
60
+ ---
61
+
62
+ ## Install
63
+
64
+ From **PyPI**:
65
+
66
+ ```bash
67
+ pip install cyclo-manager
68
+ ```
69
+
70
+ Pre-release versions:
71
+
72
+ ```bash
73
+ pip install --pre cyclo-manager==0.2.0.dev0
74
+ ```
75
+
76
+ If `cyclo_manager` is not on `PATH`, add the user script directory:
77
+
78
+ ```bash
79
+ export PATH="$HOME/.local/bin:$PATH"
80
+ cyclo_manager up
81
+ ```
82
+
83
+ If the entry point is missing:
84
+
85
+ ```bash
86
+ python3 -m cyclo_manager_cli.cli --help
87
+ python3 -m cyclo_manager_cli.cli up
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Commands
93
+
94
+ | Command | Behavior |
95
+ |--------|----------|
96
+ | `cyclo_manager up` | Install/refresh **cyclo_host_agent** (systemd unit, socket dir, sudoers), then `docker compose up -d` for **API + UI**, then `docker compose create --no-recreate` for **Zenoh** and **noVNC** (created but **not started**). Sets **`CYCLO_MANAGER_CONFIG_FILE`** to the bundled config. |
97
+ | `cyclo_manager up --pull` | `docker compose pull` first, then the same as `up`. |
98
+ | `cyclo_manager down` | **Tear down host agent** (stop/disable service, remove unit, sudoers, socket dir), then `docker compose down` for all services in the bundled compose file. |
99
+ | `cyclo_manager update` | `docker compose down` (containers only — **host agent stays installed**), `pip install -U cyclo-manager`, `docker compose up` + create optional services, then **refresh host agent** (unit, socket, sudoers). Optional `--pull` pulls images before the final `up`. |
100
+ | `cyclo_manager --help` | Subcommand overview |
101
+
102
+ **`down` vs `update`:** `down` removes the host agent entirely. `update` only stops Docker containers, upgrades the pip package, restarts the stack, and refreshes the host agent — it does **not** call `cyclo_manager down`.
103
+
104
+ ---
105
+
106
+ ## What `cyclo_manager up` does
107
+
108
+ Three phases:
109
+
110
+ ```text
111
+ [1/3] Setting up host agent → systemd unit, socket ownership, sudoers
112
+ [2/3] Starting containers → cyclo_manager (API) + ui
113
+ [3/3] Creating (not starting) → zenoh_daemon + novnc-server
114
+ ```
115
+
116
+ Open **http://127.0.0.1:3000** (UI) and **http://127.0.0.1:8081/docs** (API).
117
+
118
+ ---
119
+
120
+ ## Host agent (`cyclo_host_agent`)
121
+
122
+ The pip package installs a small **FastAPI** server that listens on a **Unix domain socket** on the host:
123
+
124
+ | Item | Path |
125
+ |------|------|
126
+ | Socket | `/var/run/robotis/agent_sockets/host/host_agent.sock` |
127
+ | systemd unit | `cyclo_host_agent.service` |
128
+ | sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts) |
129
+
130
+ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `config.yml`).
131
+
132
+ **Responsibilities:**
133
+
134
+ - List and update **`~/ROBOTIS-GIT/*`** git repositories (Version Management in the UI)
135
+ - Run **`cyclo_manager update`** when triggered from the UI (`POST /host/update`)
136
+
137
+ `cyclo_manager up` and `cyclo_manager update` both call `_ensure_host_agent()`, which is idempotent — safe to run after upgrades or user changes.
138
+
139
+ ---
140
+
141
+ ## Compose services
142
+
143
+ Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml). All services use **`network_mode: host`**.
144
+
145
+ | Compose service | Container name | `cyclo_manager up` | Image (example) |
146
+ |-----------------|----------------|--------------------|-----------------|
147
+ | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:0.2.0` |
148
+ | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:0.2.0` |
149
+ | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
150
+ | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
151
+
152
+ Start optional containers from the UI or manually, e.g. `docker start zenoh_daemon`.
153
+
154
+ ---
155
+
156
+ ## Configuration
157
+
158
+ The CLI always mounts the **bundled** config into the API container. There is **no** `-c` / `--config` flag on the pip CLI.
159
+
160
+ **Bundled path:** [`cyclo_manager_cli/config/config.yml`](cyclo_manager_cli/config/config.yml)
161
+
162
+ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
163
+
164
+ ### Schema
165
+
166
+ | Key | Description |
167
+ |-----|-------------|
168
+ | **`robot_container`** | Primary robot Docker container name (e.g. `ai_worker`). Used by the UI System page and service bringup. Must be a key in `sockets` and cannot be `host_agent`. |
169
+ | **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
170
+
171
+ s6 **service names** are not listed in config; each in-container agent reports them at runtime.
172
+
173
+ ### Example (bundled default)
174
+
175
+ ```yaml
176
+ robot_container: ai_worker
177
+
178
+ sockets:
179
+ ai_worker: "/agents/ai_worker/s6_agent.sock"
180
+ physical_ai_server: "/agents/physical_ai_server/s6_agent.sock"
181
+ host_agent: "/agents/host/host_agent.sock"
182
+ ```
183
+
184
+ ### Validation (API startup)
185
+
186
+ - At least one socket entry besides `host_agent`
187
+ - `robot_container` must exist in `sockets`
188
+ - All socket paths must be non-empty strings
189
+
190
+ To use a different config layout, mounts, or local source builds, use the repository’s **`docker-compose.dev.yml`** — see [Custom config and development](#custom-config-and-development).
191
+
192
+ ---
193
+
194
+ ## Agent sockets on the host
195
+
196
+ On the host, sockets typically live under:
197
+
198
+ ```text
199
+ /var/run/robotis/agent_sockets/
200
+ ├── ai_worker/s6_agent.sock
201
+ ├── physical_ai_server/s6_agent.sock
202
+ └── host/host_agent.sock ← created by cyclo_manager up
203
+ ```
204
+
205
+ The bundled compose file mounts the parent directory:
206
+
207
+ ```yaml
208
+ - /var/run/robotis/agent_sockets:/agents
209
+ ```
210
+
211
+ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path above.
212
+
213
+ ---
214
+
215
+ ## Environment variables
216
+
217
+ | Variable | Set by | Purpose |
218
+ |----------|--------|---------|
219
+ | **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
220
+ | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
221
+ | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
222
+ | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
223
+
224
+ ---
225
+
226
+ ## URLs (packaged stack)
227
+
228
+ With `network_mode: host` and default ports:
229
+
230
+ | What | URL |
231
+ |------|-----|
232
+ | Web UI | http://127.0.0.1:3000 |
233
+ | cyclo_manager API | http://127.0.0.1:8081 |
234
+ | OpenAPI (Swagger) | http://127.0.0.1:8081/docs |
235
+
236
+ ---
237
+
238
+ ## Custom config and development
239
+
240
+ The pip workflow is intended for **production robots** with pre-built images.
241
+
242
+ For development (local API/UI source, custom `config.yml`, hot reload):
243
+
244
+ ```bash
245
+ # From repository root
246
+ docker compose -f docker-compose.dev.yml up
247
+ ```
248
+
249
+ See the [root README](../README.md#development).
250
+
251
+ ---
252
+
253
+ ## Dependencies
254
+
255
+ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
256
+
257
+ | Package | Used by |
258
+ |---------|---------|
259
+ | **fastapi** | `cyclo_host_agent` HTTP API |
260
+ | **uvicorn** | `cyclo_host_agent` server |
261
+ | **psutil** | Host agent / system utilities |
262
+
263
+ The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
264
+
265
+ Runtime **outside** Python:
266
+
267
+ - **Docker** + **Compose v2**
268
+ - **systemd** (host agent service)
269
+ - Pre-built **Docker images** referenced in the bundled compose file
270
+
271
+ ---
272
+
273
+ ## License
274
+
275
+ SPDX: **Apache-2.0** (see [pyproject.toml](pyproject.toml) and the repository [LICENSE](../LICENSE)).
@@ -0,0 +1,261 @@
1
+ # cyclo_manager CLI
2
+
3
+ **PyPI package:** `cyclo-manager`
4
+ **Console commands:** `cyclo_manager` (underscore), `cyclo-manager` (hyphen)
5
+ **Host agent:** `cyclo_host_agent` (installed by the package, run via systemd)
6
+
7
+ This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It:
8
+
9
+ 1. Runs **`docker compose`** against a **bundled** [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml) to start the **API** and **web UI** containers.
10
+ 2. Installs and maintains **`cyclo_host_agent`** as a **systemd** service for host-level operations (git repo updates, `cyclo_manager` package update from the UI).
11
+ 3. Can **`pip install -U`** itself and bring the Docker stack back up.
12
+
13
+ Implementation: [`cyclo_manager_cli/cli.py`](cyclo_manager_cli/cli.py), [`cyclo_host_agent/`](cyclo_host_agent/).
14
+
15
+ For the full monorepo (API source, UI source, dev compose), see the [repository root README](../README.md).
16
+
17
+ ---
18
+
19
+ ## Table of contents
20
+
21
+ - [Prerequisites](#prerequisites)
22
+ - [Install](#install)
23
+ - [Commands](#commands)
24
+ - [What `cyclo_manager up` does](#what-cyclo_manager-up-does)
25
+ - [Host agent (`cyclo_host_agent`)](#host-agent-cyclo_host_agent)
26
+ - [Compose services](#compose-services)
27
+ - [Configuration](#configuration)
28
+ - [Agent sockets on the host](#agent-sockets-on-the-host)
29
+ - [Environment variables](#environment-variables)
30
+ - [URLs (packaged stack)](#urls-packaged-stack)
31
+ - [Custom config and development](#custom-config-and-development)
32
+ - [Dependencies](#dependencies)
33
+ - [License](#license)
34
+
35
+ ---
36
+
37
+ ## Prerequisites
38
+
39
+ - **Docker** with **Compose v2** (`docker compose` — the legacy `docker-compose` binary alone is not enough)
40
+ - **Python 3.10+**
41
+ - **`sudo`** — required to install the host agent systemd unit, socket directory permissions, and sudoers rules
42
+ - For **`cyclo_manager update`:** **`pip`** or **`pip3`** on `PATH`
43
+ - **Do not run as root** — the host agent runs as the invoking user (or `SUDO_USER` when using `sudo cyclo_manager up`). Running as root is rejected.
44
+ - **Agent sockets** on the host under `/var/run/robotis/agent_sockets/` (robot containers and host agent). The bundled Compose file bind-mounts this tree into the API container as `/agents/`.
45
+
46
+ ---
47
+
48
+ ## Install
49
+
50
+ From **PyPI**:
51
+
52
+ ```bash
53
+ pip install cyclo-manager
54
+ ```
55
+
56
+ Pre-release versions:
57
+
58
+ ```bash
59
+ pip install --pre cyclo-manager==0.2.0.dev0
60
+ ```
61
+
62
+ If `cyclo_manager` is not on `PATH`, add the user script directory:
63
+
64
+ ```bash
65
+ export PATH="$HOME/.local/bin:$PATH"
66
+ cyclo_manager up
67
+ ```
68
+
69
+ If the entry point is missing:
70
+
71
+ ```bash
72
+ python3 -m cyclo_manager_cli.cli --help
73
+ python3 -m cyclo_manager_cli.cli up
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Commands
79
+
80
+ | Command | Behavior |
81
+ |--------|----------|
82
+ | `cyclo_manager up` | Install/refresh **cyclo_host_agent** (systemd unit, socket dir, sudoers), then `docker compose up -d` for **API + UI**, then `docker compose create --no-recreate` for **Zenoh** and **noVNC** (created but **not started**). Sets **`CYCLO_MANAGER_CONFIG_FILE`** to the bundled config. |
83
+ | `cyclo_manager up --pull` | `docker compose pull` first, then the same as `up`. |
84
+ | `cyclo_manager down` | **Tear down host agent** (stop/disable service, remove unit, sudoers, socket dir), then `docker compose down` for all services in the bundled compose file. |
85
+ | `cyclo_manager update` | `docker compose down` (containers only — **host agent stays installed**), `pip install -U cyclo-manager`, `docker compose up` + create optional services, then **refresh host agent** (unit, socket, sudoers). Optional `--pull` pulls images before the final `up`. |
86
+ | `cyclo_manager --help` | Subcommand overview |
87
+
88
+ **`down` vs `update`:** `down` removes the host agent entirely. `update` only stops Docker containers, upgrades the pip package, restarts the stack, and refreshes the host agent — it does **not** call `cyclo_manager down`.
89
+
90
+ ---
91
+
92
+ ## What `cyclo_manager up` does
93
+
94
+ Three phases:
95
+
96
+ ```text
97
+ [1/3] Setting up host agent → systemd unit, socket ownership, sudoers
98
+ [2/3] Starting containers → cyclo_manager (API) + ui
99
+ [3/3] Creating (not starting) → zenoh_daemon + novnc-server
100
+ ```
101
+
102
+ Open **http://127.0.0.1:3000** (UI) and **http://127.0.0.1:8081/docs** (API).
103
+
104
+ ---
105
+
106
+ ## Host agent (`cyclo_host_agent`)
107
+
108
+ The pip package installs a small **FastAPI** server that listens on a **Unix domain socket** on the host:
109
+
110
+ | Item | Path |
111
+ |------|------|
112
+ | Socket | `/var/run/robotis/agent_sockets/host/host_agent.sock` |
113
+ | systemd unit | `cyclo_host_agent.service` |
114
+ | sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts) |
115
+
116
+ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `config.yml`).
117
+
118
+ **Responsibilities:**
119
+
120
+ - List and update **`~/ROBOTIS-GIT/*`** git repositories (Version Management in the UI)
121
+ - Run **`cyclo_manager update`** when triggered from the UI (`POST /host/update`)
122
+
123
+ `cyclo_manager up` and `cyclo_manager update` both call `_ensure_host_agent()`, which is idempotent — safe to run after upgrades or user changes.
124
+
125
+ ---
126
+
127
+ ## Compose services
128
+
129
+ Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml). All services use **`network_mode: host`**.
130
+
131
+ | Compose service | Container name | `cyclo_manager up` | Image (example) |
132
+ |-----------------|----------------|--------------------|-----------------|
133
+ | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:0.2.0` |
134
+ | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:0.2.0` |
135
+ | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
136
+ | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
137
+
138
+ Start optional containers from the UI or manually, e.g. `docker start zenoh_daemon`.
139
+
140
+ ---
141
+
142
+ ## Configuration
143
+
144
+ The CLI always mounts the **bundled** config into the API container. There is **no** `-c` / `--config` flag on the pip CLI.
145
+
146
+ **Bundled path:** [`cyclo_manager_cli/config/config.yml`](cyclo_manager_cli/config/config.yml)
147
+
148
+ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
149
+
150
+ ### Schema
151
+
152
+ | Key | Description |
153
+ |-----|-------------|
154
+ | **`robot_container`** | Primary robot Docker container name (e.g. `ai_worker`). Used by the UI System page and service bringup. Must be a key in `sockets` and cannot be `host_agent`. |
155
+ | **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
156
+
157
+ s6 **service names** are not listed in config; each in-container agent reports them at runtime.
158
+
159
+ ### Example (bundled default)
160
+
161
+ ```yaml
162
+ robot_container: ai_worker
163
+
164
+ sockets:
165
+ ai_worker: "/agents/ai_worker/s6_agent.sock"
166
+ physical_ai_server: "/agents/physical_ai_server/s6_agent.sock"
167
+ host_agent: "/agents/host/host_agent.sock"
168
+ ```
169
+
170
+ ### Validation (API startup)
171
+
172
+ - At least one socket entry besides `host_agent`
173
+ - `robot_container` must exist in `sockets`
174
+ - All socket paths must be non-empty strings
175
+
176
+ To use a different config layout, mounts, or local source builds, use the repository’s **`docker-compose.dev.yml`** — see [Custom config and development](#custom-config-and-development).
177
+
178
+ ---
179
+
180
+ ## Agent sockets on the host
181
+
182
+ On the host, sockets typically live under:
183
+
184
+ ```text
185
+ /var/run/robotis/agent_sockets/
186
+ ├── ai_worker/s6_agent.sock
187
+ ├── physical_ai_server/s6_agent.sock
188
+ └── host/host_agent.sock ← created by cyclo_manager up
189
+ ```
190
+
191
+ The bundled compose file mounts the parent directory:
192
+
193
+ ```yaml
194
+ - /var/run/robotis/agent_sockets:/agents
195
+ ```
196
+
197
+ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path above.
198
+
199
+ ---
200
+
201
+ ## Environment variables
202
+
203
+ | Variable | Set by | Purpose |
204
+ |----------|--------|---------|
205
+ | **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
206
+ | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
207
+ | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
208
+ | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
209
+
210
+ ---
211
+
212
+ ## URLs (packaged stack)
213
+
214
+ With `network_mode: host` and default ports:
215
+
216
+ | What | URL |
217
+ |------|-----|
218
+ | Web UI | http://127.0.0.1:3000 |
219
+ | cyclo_manager API | http://127.0.0.1:8081 |
220
+ | OpenAPI (Swagger) | http://127.0.0.1:8081/docs |
221
+
222
+ ---
223
+
224
+ ## Custom config and development
225
+
226
+ The pip workflow is intended for **production robots** with pre-built images.
227
+
228
+ For development (local API/UI source, custom `config.yml`, hot reload):
229
+
230
+ ```bash
231
+ # From repository root
232
+ docker compose -f docker-compose.dev.yml up
233
+ ```
234
+
235
+ See the [root README](../README.md#development).
236
+
237
+ ---
238
+
239
+ ## Dependencies
240
+
241
+ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
242
+
243
+ | Package | Used by |
244
+ |---------|---------|
245
+ | **fastapi** | `cyclo_host_agent` HTTP API |
246
+ | **uvicorn** | `cyclo_host_agent` server |
247
+ | **psutil** | Host agent / system utilities |
248
+
249
+ The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
250
+
251
+ Runtime **outside** Python:
252
+
253
+ - **Docker** + **Compose v2**
254
+ - **systemd** (host agent service)
255
+ - Pre-built **Docker images** referenced in the bundled compose file
256
+
257
+ ---
258
+
259
+ ## License
260
+
261
+ SPDX: **Apache-2.0** (see [pyproject.toml](pyproject.toml) and the repository [LICENSE](../LICENSE)).
@@ -71,6 +71,14 @@ class RepoStatusResponse(BaseModel):
71
71
  has_changes: bool
72
72
 
73
73
 
74
+ class RepoBranchCheckResponse(BaseModel):
75
+ """Response for GET /repos/{name}/branch."""
76
+
77
+ name: str
78
+ branch: Optional[str] = None
79
+ allowed: bool
80
+
81
+
74
82
  class UpdateRequest(BaseModel):
75
83
  """Request body for POST /repos/{name}/update."""
76
84
 
@@ -29,6 +29,7 @@ from fastapi import APIRouter, HTTPException, status
29
29
  from cyclo_host_agent.models import (
30
30
  ContainerScriptResponse,
31
31
  FileChange,
32
+ RepoBranchCheckResponse,
32
33
  RepoInfo,
33
34
  RepoListResponse,
34
35
  RepoStatusResponse,
@@ -52,6 +53,7 @@ def _resolve_home() -> Path:
52
53
 
53
54
  WORKSPACE_PATH = _resolve_home()
54
55
  MANAGED_GITHUB_ORG = 'ROBOTIS-GIT'
56
+ ALLOWED_UPDATE_BRANCHES = frozenset({'main', 'jazzy'})
55
57
 
56
58
 
57
59
  # ── shared helpers ─────────────────────────────────────────────────────────────
@@ -84,6 +86,41 @@ def _fmt_cmd(cmd: str, output: str) -> str:
84
86
 
85
87
  # ── git helpers ────────────────────────────────────────────────────────────────
86
88
 
89
+ async def _stash_count(cwd: Path) -> int:
90
+ rc, out, _ = await _git(['stash', 'list'], cwd=cwd)
91
+ if rc != 0:
92
+ return 0
93
+ return sum(1 for line in out.splitlines() if line.strip())
94
+
95
+
96
+ async def _current_branch(repo_path: Path) -> Optional[str]:
97
+ rc, out, _ = await _git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd=repo_path)
98
+ if rc != 0:
99
+ return None
100
+ branch = out.strip()
101
+ if not branch or branch == 'HEAD':
102
+ return None
103
+ return branch
104
+
105
+
106
+ async def _ensure_allowed_branch(repo_path: Path) -> None:
107
+ branch = await _current_branch(repo_path)
108
+ if branch in ALLOWED_UPDATE_BRANCHES:
109
+ return
110
+ allowed = ', '.join(sorted(ALLOWED_UPDATE_BRANCHES))
111
+ if branch:
112
+ detail = (
113
+ f"Updates are only allowed on branches: {allowed}. "
114
+ f"Current branch is '{branch}'."
115
+ )
116
+ else:
117
+ detail = (
118
+ f"Updates are only allowed on branches: {allowed}. "
119
+ 'Could not determine the current branch (detached HEAD?).'
120
+ )
121
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
122
+
123
+
87
124
  async def _git(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
88
125
  proc = await asyncio.create_subprocess_exec(
89
126
  'git', *args,
@@ -202,17 +239,23 @@ async def _update_stash(repo_path: Path) -> UpdateResponse:
202
239
  name = repo_path.name
203
240
  lines: list[str] = []
204
241
 
242
+ stash_before = await _stash_count(repo_path)
205
243
  rc, out, err = await _git(['stash', '-u'], cwd=repo_path)
206
244
  lines.append(_fmt_cmd('git stash -u', out + err))
207
245
  if rc != 0:
208
246
  return UpdateResponse(name=name, success=False, output='\n'.join(lines))
247
+ stashed = await _stash_count(repo_path) > stash_before
209
248
 
210
249
  rc, out, err = await _git(['pull'], cwd=repo_path)
211
250
  lines.append(_fmt_cmd('git pull', out + err))
212
251
  if rc != 0:
213
- await _git(['stash', 'pop'], cwd=repo_path)
252
+ if stashed:
253
+ await _git(['stash', 'pop'], cwd=repo_path)
214
254
  return UpdateResponse(name=name, success=False, output='\n'.join(lines))
215
255
 
256
+ if not stashed:
257
+ return UpdateResponse(name=name, success=True, output='\n'.join(lines))
258
+
216
259
  rc, out, err = await _git(['stash', 'pop'], cwd=repo_path)
217
260
  lines.append(_fmt_cmd('git stash pop', out + err))
218
261
 
@@ -314,6 +357,17 @@ async def list_repos() -> RepoListResponse:
314
357
  return RepoListResponse(repos=repos, workspace_path=str(WORKSPACE_PATH))
315
358
 
316
359
 
360
+ @router.get('/{name}/branch', response_model=RepoBranchCheckResponse)
361
+ async def get_repo_branch(name: str) -> RepoBranchCheckResponse:
362
+ repo_path = _get_repo(name)
363
+ branch = await _current_branch(repo_path)
364
+ return RepoBranchCheckResponse(
365
+ name=name,
366
+ branch=branch,
367
+ allowed=branch in ALLOWED_UPDATE_BRANCHES,
368
+ )
369
+
370
+
317
371
  @router.get('/{name}/status', response_model=RepoStatusResponse)
318
372
  async def get_repo_status(name: str) -> RepoStatusResponse:
319
373
  repo_path = _get_repo(name)
@@ -347,6 +401,7 @@ async def start_repo_container(name: str) -> ContainerScriptResponse:
347
401
  @router.post('/{name}/update', response_model=UpdateResponse)
348
402
  async def update_repo(name: str, req: UpdateRequest) -> UpdateResponse:
349
403
  repo_path = _get_repo(name)
404
+ await _ensure_allowed_branch(repo_path)
350
405
  try:
351
406
  if req.strategy == 'reset':
352
407
  return await _update_reset(repo_path, req.preserve_files)
@@ -62,6 +62,10 @@ async def start_update() -> dict:
62
62
  raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
63
63
  detail='cyclo_manager command not found')
64
64
 
65
+ if _update_status.get('phase') == 'updating':
66
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT,
67
+ detail='Update already in progress')
68
+
65
69
  _update_status = {'phase': 'updating', 'output': '', 'error': ''}
66
70
  asyncio.create_task(_run_update(cyclo_exe))
67
71
  return {'status': 'update started'}