cyclo-manager 1.1.0.dev2__tar.gz → 1.1.0.dev4__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 (24) hide show
  1. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/PKG-INFO +53 -8
  2. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/README.md +52 -7
  3. cyclo_manager-1.1.0.dev4/cyclo_host_agent/cpu_usage.py +84 -0
  4. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/main.py +14 -0
  5. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/files.py +47 -25
  6. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/repos.py +59 -36
  7. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/system_stats.py +17 -7
  8. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/PKG-INFO +53 -8
  9. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/SOURCES.txt +3 -1
  10. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/__init__.py +1 -1
  11. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/docker/docker-compose.yml +2 -0
  12. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/pyproject.toml +1 -1
  13. cyclo_manager-1.1.0.dev4/tests/test_cpu_usage.py +169 -0
  14. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/__init__.py +0 -0
  15. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/models.py +0 -0
  16. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/__init__.py +0 -0
  17. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/update.py +0 -0
  18. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  19. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/entry_points.txt +0 -0
  20. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/requires.txt +0 -0
  21. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/top_level.txt +0 -0
  22. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/cli.py +0 -0
  23. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/config/config.yml +0 -0
  24. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 1.1.0.dev2
3
+ Version: 1.1.0.dev4
4
4
  Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -38,6 +38,7 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
38
38
  - [What `cyclo_manager up` does](#what-cyclo_manager-up-does)
39
39
  - [Host agent (`cyclo_host_agent`)](#host-agent-cyclo_host_agent)
40
40
  - [Compose services](#compose-services)
41
+ - [Recording storage](#recording-storage)
41
42
  - [Configuration](#configuration)
42
43
  - [Agent sockets on the host](#agent-sockets-on-the-host)
43
44
  - [Environment variables](#environment-variables)
@@ -134,6 +135,10 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
134
135
  - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
135
136
  - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
136
137
  - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
138
+ - Provide host CPU/memory/disk statistics and process information. CPU summaries
139
+ share a background sampler's average of the latest three one-second samples.
140
+ - Serve the Files UI's browsing, filename search, upload, text editing, Git diff
141
+ and file/directory management operations under the configured workspace.
137
142
 
138
143
  `cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
139
144
 
@@ -145,10 +150,10 @@ Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set.
145
150
 
146
151
  Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml). All services use **`network_mode: host`**.
147
152
 
148
- | Compose service | Container name | `cyclo_manager up` | Image (example) |
153
+ | Compose service | Container name | `cyclo_manager up` | Image in this checkout |
149
154
  |-----------------|----------------|--------------------|-----------------|
150
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.0` |
151
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.0` |
155
+ | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.1.0` |
156
+ | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.1.0` |
152
157
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
153
158
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
154
159
 
@@ -156,6 +161,27 @@ Start optional containers from the UI or manually, e.g. `docker start zenoh_daem
156
161
 
157
162
  ---
158
163
 
164
+ ## Recording storage
165
+
166
+ The packaged stack and dev compose both mount:
167
+
168
+ ```yaml
169
+ - ${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags
170
+ ```
171
+
172
+ Compose resolves `${HOME}` on the host at invocation time. The API container uses
173
+ `RECORDINGS_DIR=/cyclo_manager_ros_bags`, which is also the server default when the
174
+ variable is absent. The host directory is independent of
175
+ `CYCLO_HOST_AGENT_WORKSPACE`; each recording stores its metadata and MCAP bag under
176
+ its generated ID. Container recreation, `down` and package updates preserve this
177
+ bind-mounted data. Applying a new mount requires container recreation.
178
+
179
+ Recording and playback run in the API container using `rosbag2_py` and the MCAP
180
+ storage plugin included in the manager image. They are not host-agent jobs.
181
+ See [Record & Play](../docs/record-play.md) for capture, repeat playback and stops.
182
+
183
+ ---
184
+
159
185
  ## Configuration
160
186
 
161
187
  The CLI always mounts the **bundled** config into the API container. There is **no** `-c` / `--config` flag on the pip CLI.
@@ -168,10 +194,13 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
168
194
 
169
195
  | Key | Description |
170
196
  |-----|-------------|
171
- | **`supported_robot_containers`** | Robot Docker container names that can open the System page (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
197
+ | **`supported_robot_containers`** | Containers eligible for System navigation and server-side motion profile detection (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
172
198
  | **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
173
199
 
174
- s6 **service names** are not listed in config; each in-container agent reports them at runtime.
200
+ s6 **service names** are defined in the manager's System/motion profiles, not in
201
+ this config. Motion detection checks the existing individual status endpoints for
202
+ `ai_worker_bringup` and `open_manipulator_bringup`, then reads `/run/robot_type`
203
+ inside the selected container through Docker. This needs no new s6-agent endpoint.
175
204
 
176
205
  ### Example (bundled default)
177
206
 
@@ -198,6 +227,7 @@ On the host, sockets typically live under:
198
227
  ```text
199
228
  /var/run/robotis/agent_sockets/
200
229
  ├── ai_worker/s6_agent.sock
230
+ ├── open_manipulator/s6_agent.sock
201
231
  ├── cyclo_intelligence/s6_agent.sock
202
232
  └── host/host_agent.sock ← created by cyclo_manager up
203
233
  ```
@@ -220,7 +250,11 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
220
250
  | **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
221
251
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
222
252
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
223
- | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
253
+ | **`HOME`** | Host environment used by Compose | Selects the host `${HOME}/cyclo_manager_ros_bags` bind-mount directory |
254
+ | **`RECORDINGS_DIR`** | Compose (`/cyclo_manager_ros_bags`) | Server-side recording directory inside the API container; same default without the variable |
255
+ | **`ROS_DOMAIN_ID`** | Manager image shell startup (`30`); not set by CLI | Must match the robot; the manager image configures it in `/root/.bashrc`, which the API startup sources |
256
+ | **`RMW_IMPLEMENTATION`** | Manager image shell startup (`rmw_zenoh_cpp`) | ROS middleware used by the manager; requires a reachable Zenoh router |
257
+ | **`NEXT_PUBLIC_API_URL`** | UI dev/build environment | Browser API address; production bundles embed it at build time, so a compose runtime value does not reconfigure a prebuilt UI |
224
258
 
225
259
  ---
226
260
 
@@ -234,6 +268,12 @@ With `network_mode: host` and default ports:
234
268
  | cyclo_manager API | http://127.0.0.1:8081 |
235
269
  | OpenAPI (Swagger) | http://127.0.0.1:8081/docs |
236
270
 
271
+ These loopback URLs apply on the robot host. From another device, use the robot's
272
+ hostname/IP and a browser-reachable API URL. Both compose files contain a UI
273
+ runtime value of `http://127.0.0.1:8081`; it takes effect in development, while a
274
+ production bundle uses its build-time value or hostname fallback. See the
275
+ [UI API address guide](../cyclo_manager_ui/README_UI.md#api-address).
276
+
237
277
  ---
238
278
 
239
279
  ## Custom config and development
@@ -247,6 +287,11 @@ For development (local API/UI source, custom `config.yml`, hot reload):
247
287
  docker compose -f docker-compose.dev.yml up
248
288
  ```
249
289
 
290
+ The dev compose command starts all defined services and does not install the host
291
+ agent. API/UI source mounts do not update the Python code used by the separately
292
+ installed `cyclo_host_agent.service`; install the updated CLI package and refresh
293
+ that service when testing host-agent changes.
294
+
250
295
  See the [root README](../README.md#development).
251
296
 
252
297
  ---
@@ -259,7 +304,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
259
304
  |---------|---------|
260
305
  | **fastapi** | `cyclo_host_agent` HTTP API |
261
306
  | **uvicorn** | `cyclo_host_agent` server |
262
- | **psutil** | Declared package dependency for host/system utility support |
307
+ | **psutil** | Host CPU sampler, memory/disk statistics and process inspection |
263
308
 
264
309
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
265
310
 
@@ -24,6 +24,7 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
24
24
  - [What `cyclo_manager up` does](#what-cyclo_manager-up-does)
25
25
  - [Host agent (`cyclo_host_agent`)](#host-agent-cyclo_host_agent)
26
26
  - [Compose services](#compose-services)
27
+ - [Recording storage](#recording-storage)
27
28
  - [Configuration](#configuration)
28
29
  - [Agent sockets on the host](#agent-sockets-on-the-host)
29
30
  - [Environment variables](#environment-variables)
@@ -120,6 +121,10 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
120
121
  - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
121
122
  - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
122
123
  - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
124
+ - Provide host CPU/memory/disk statistics and process information. CPU summaries
125
+ share a background sampler's average of the latest three one-second samples.
126
+ - Serve the Files UI's browsing, filename search, upload, text editing, Git diff
127
+ and file/directory management operations under the configured workspace.
123
128
 
124
129
  `cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
125
130
 
@@ -131,10 +136,10 @@ Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set.
131
136
 
132
137
  Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml). All services use **`network_mode: host`**.
133
138
 
134
- | Compose service | Container name | `cyclo_manager up` | Image (example) |
139
+ | Compose service | Container name | `cyclo_manager up` | Image in this checkout |
135
140
  |-----------------|----------------|--------------------|-----------------|
136
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.0` |
137
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.0` |
141
+ | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.1.0` |
142
+ | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.1.0` |
138
143
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
139
144
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
140
145
 
@@ -142,6 +147,27 @@ Start optional containers from the UI or manually, e.g. `docker start zenoh_daem
142
147
 
143
148
  ---
144
149
 
150
+ ## Recording storage
151
+
152
+ The packaged stack and dev compose both mount:
153
+
154
+ ```yaml
155
+ - ${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags
156
+ ```
157
+
158
+ Compose resolves `${HOME}` on the host at invocation time. The API container uses
159
+ `RECORDINGS_DIR=/cyclo_manager_ros_bags`, which is also the server default when the
160
+ variable is absent. The host directory is independent of
161
+ `CYCLO_HOST_AGENT_WORKSPACE`; each recording stores its metadata and MCAP bag under
162
+ its generated ID. Container recreation, `down` and package updates preserve this
163
+ bind-mounted data. Applying a new mount requires container recreation.
164
+
165
+ Recording and playback run in the API container using `rosbag2_py` and the MCAP
166
+ storage plugin included in the manager image. They are not host-agent jobs.
167
+ See [Record & Play](../docs/record-play.md) for capture, repeat playback and stops.
168
+
169
+ ---
170
+
145
171
  ## Configuration
146
172
 
147
173
  The CLI always mounts the **bundled** config into the API container. There is **no** `-c` / `--config` flag on the pip CLI.
@@ -154,10 +180,13 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
154
180
 
155
181
  | Key | Description |
156
182
  |-----|-------------|
157
- | **`supported_robot_containers`** | Robot Docker container names that can open the System page (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
183
+ | **`supported_robot_containers`** | Containers eligible for System navigation and server-side motion profile detection (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
158
184
  | **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
159
185
 
160
- s6 **service names** are not listed in config; each in-container agent reports them at runtime.
186
+ s6 **service names** are defined in the manager's System/motion profiles, not in
187
+ this config. Motion detection checks the existing individual status endpoints for
188
+ `ai_worker_bringup` and `open_manipulator_bringup`, then reads `/run/robot_type`
189
+ inside the selected container through Docker. This needs no new s6-agent endpoint.
161
190
 
162
191
  ### Example (bundled default)
163
192
 
@@ -184,6 +213,7 @@ On the host, sockets typically live under:
184
213
  ```text
185
214
  /var/run/robotis/agent_sockets/
186
215
  ├── ai_worker/s6_agent.sock
216
+ ├── open_manipulator/s6_agent.sock
187
217
  ├── cyclo_intelligence/s6_agent.sock
188
218
  └── host/host_agent.sock ← created by cyclo_manager up
189
219
  ```
@@ -206,7 +236,11 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
206
236
  | **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
207
237
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
208
238
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
209
- | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
239
+ | **`HOME`** | Host environment used by Compose | Selects the host `${HOME}/cyclo_manager_ros_bags` bind-mount directory |
240
+ | **`RECORDINGS_DIR`** | Compose (`/cyclo_manager_ros_bags`) | Server-side recording directory inside the API container; same default without the variable |
241
+ | **`ROS_DOMAIN_ID`** | Manager image shell startup (`30`); not set by CLI | Must match the robot; the manager image configures it in `/root/.bashrc`, which the API startup sources |
242
+ | **`RMW_IMPLEMENTATION`** | Manager image shell startup (`rmw_zenoh_cpp`) | ROS middleware used by the manager; requires a reachable Zenoh router |
243
+ | **`NEXT_PUBLIC_API_URL`** | UI dev/build environment | Browser API address; production bundles embed it at build time, so a compose runtime value does not reconfigure a prebuilt UI |
210
244
 
211
245
  ---
212
246
 
@@ -220,6 +254,12 @@ With `network_mode: host` and default ports:
220
254
  | cyclo_manager API | http://127.0.0.1:8081 |
221
255
  | OpenAPI (Swagger) | http://127.0.0.1:8081/docs |
222
256
 
257
+ These loopback URLs apply on the robot host. From another device, use the robot's
258
+ hostname/IP and a browser-reachable API URL. Both compose files contain a UI
259
+ runtime value of `http://127.0.0.1:8081`; it takes effect in development, while a
260
+ production bundle uses its build-time value or hostname fallback. See the
261
+ [UI API address guide](../cyclo_manager_ui/README_UI.md#api-address).
262
+
223
263
  ---
224
264
 
225
265
  ## Custom config and development
@@ -233,6 +273,11 @@ For development (local API/UI source, custom `config.yml`, hot reload):
233
273
  docker compose -f docker-compose.dev.yml up
234
274
  ```
235
275
 
276
+ The dev compose command starts all defined services and does not install the host
277
+ agent. API/UI source mounts do not update the Python code used by the separately
278
+ installed `cyclo_host_agent.service`; install the updated CLI package and refresh
279
+ that service when testing host-agent changes.
280
+
236
281
  See the [root README](../README.md#development).
237
282
 
238
283
  ---
@@ -245,7 +290,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
245
290
  |---------|---------|
246
291
  | **fastapi** | `cyclo_host_agent` HTTP API |
247
292
  | **uvicorn** | `cyclo_host_agent` server |
248
- | **psutil** | Declared package dependency for host/system utility support |
293
+ | **psutil** | Host CPU sampler, memory/disk statistics and process inspection |
249
294
 
250
295
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
251
296
 
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """Continuous host CPU sampling, independent of HTTP requests."""
20
+
21
+ from collections import deque
22
+ import logging
23
+ import threading
24
+
25
+ import psutil
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ CPU_SAMPLE_INTERVAL_SECONDS = 1.0
30
+ CPU_AVERAGE_SAMPLES = 3
31
+
32
+
33
+ class CpuUsageSampler:
34
+ """Keep the mean of the latest three consecutive one-second CPU samples."""
35
+
36
+ def __init__(self, interval: float = CPU_SAMPLE_INTERVAL_SECONDS) -> None:
37
+ """Initialize the sampler without starting a background thread."""
38
+ self._interval = interval
39
+ self._samples: deque[float] = deque(maxlen=CPU_AVERAGE_SAMPLES)
40
+ self._lock = threading.Lock()
41
+ self._stop = threading.Event()
42
+ self._thread: threading.Thread | None = None
43
+
44
+ def start(self) -> None:
45
+ """Start one sampling thread and discard samples from any previous run."""
46
+ if self._thread is not None and self._thread.is_alive():
47
+ return
48
+ with self._lock:
49
+ self._samples.clear()
50
+ self._stop.clear()
51
+ self._thread = threading.Thread(
52
+ target=self._run, name='host-cpu-sampler', daemon=True,
53
+ )
54
+ self._thread.start()
55
+
56
+ def stop(self) -> None:
57
+ """Wake and join the sampler on application shutdown."""
58
+ self._stop.set()
59
+ if self._thread is not None:
60
+ self._thread.join()
61
+ self._thread = None
62
+
63
+ def average(self) -> float | None:
64
+ """Return a cached average, or None before the first complete interval."""
65
+ with self._lock:
66
+ if not self._samples:
67
+ return None
68
+ return round(sum(self._samples) / len(self._samples), 1)
69
+
70
+ def _run(self) -> None:
71
+ while not self._stop.is_set():
72
+ try:
73
+ # Prime and read on the same thread. The initial value is not a sample.
74
+ psutil.cpu_percent(interval=None)
75
+ while not self._stop.wait(self._interval):
76
+ sample = psutil.cpu_percent(interval=None)
77
+ with self._lock:
78
+ self._samples.append(float(sample))
79
+ except Exception:
80
+ # Re-prime after a failure instead of mixing stale measurement intervals.
81
+ with self._lock:
82
+ self._samples.clear()
83
+ logger.exception('Failed to sample host CPU usage')
84
+ self._stop.wait(self._interval)
@@ -18,6 +18,8 @@
18
18
 
19
19
  """Cyclo host agent: FastAPI server on Unix Domain Socket for host-level operations."""
20
20
 
21
+ import asyncio
22
+ from contextlib import asynccontextmanager
21
23
  import logging
22
24
  from pathlib import Path
23
25
 
@@ -34,10 +36,22 @@ logger = logging.getLogger(__name__)
34
36
 
35
37
  SOCKET_PATH = '/var/run/robotis/agent_sockets/host/host_agent.sock'
36
38
 
39
+
40
+ @asynccontextmanager
41
+ async def lifespan(app: FastAPI):
42
+ """Run the shared CPU sampler for the lifetime of the host agent."""
43
+ system_stats.cpu_sampler.start()
44
+ try:
45
+ yield
46
+ finally:
47
+ await asyncio.to_thread(system_stats.cpu_sampler.stop)
48
+
49
+
37
50
  app = FastAPI(
38
51
  title='cyclo_host_agent',
39
52
  description='Host agent for Cyclo Manager: repo management.',
40
53
  version=__version__,
54
+ lifespan=lifespan,
41
55
  )
42
56
 
43
57
  app.include_router(repos.router)
@@ -18,6 +18,7 @@
18
18
 
19
19
  """Host file browser and editor endpoints."""
20
20
 
21
+ import asyncio
21
22
  import os
22
23
  import shutil
23
24
  import subprocess
@@ -71,6 +72,21 @@ def _safe_path(relative_path: str = '') -> tuple[Path, str]:
71
72
  return target, rel
72
73
 
73
74
 
75
+ def _safe_nofollow_path(relative_path: str = '') -> tuple[Path, str]:
76
+ raw_path = Path(relative_path)
77
+ if raw_path.is_absolute() or '..' in raw_path.parts:
78
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
79
+
80
+ lexical_target = FILE_ROOT_PATH / raw_path
81
+ parent = lexical_target.parent.resolve()
82
+ if parent != FILE_ROOT_PATH and FILE_ROOT_PATH not in parent.parents:
83
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
84
+
85
+ target = parent / lexical_target.name
86
+ rel = '' if target == FILE_ROOT_PATH else target.relative_to(FILE_ROOT_PATH).as_posix()
87
+ return target, rel
88
+
89
+
74
90
  def _safe_filename(filename: str) -> str:
75
91
  name = Path(filename).name
76
92
  if name in {'', '.', '..'} or name != filename or '/' in filename or '\\' in filename:
@@ -80,7 +96,8 @@ def _safe_filename(filename: str) -> str:
80
96
 
81
97
  def _is_binary(path: Path) -> bool:
82
98
  try:
83
- return b'\x00' in path.read_bytes()[:8192]
99
+ with path.open('rb') as file:
100
+ return b'\x00' in file.read(8192)
84
101
  except OSError:
85
102
  return True
86
103
 
@@ -262,7 +279,7 @@ def _git_head_content(repo_root: Path, repo_path: str) -> str:
262
279
 
263
280
 
264
281
  @router.get('/tree', response_model=FileTreeResponse)
265
- async def list_directory(
282
+ def list_directory(
266
283
  path: str = '',
267
284
  show_hidden: bool = Query(False),
268
285
  ) -> FileTreeResponse:
@@ -286,7 +303,7 @@ async def list_directory(
286
303
 
287
304
 
288
305
  @router.get('/search', response_model=FileSearchResponse)
289
- async def search_files(
306
+ def search_files(
290
307
  query: str,
291
308
  path: str = '',
292
309
  show_hidden: bool = Query(False),
@@ -340,7 +357,7 @@ async def search_files(
340
357
 
341
358
 
342
359
  @router.get('/read', response_model=FileReadResponse)
343
- async def read_file(
360
+ def read_file(
344
361
  path: str,
345
362
  ) -> FileReadResponse:
346
363
  """Read a UTF-8 text file under the configured file root."""
@@ -366,7 +383,7 @@ async def read_file(
366
383
 
367
384
 
368
385
  @router.get('/diff', response_model=FileDiffResponse)
369
- async def diff_file(
386
+ def diff_file(
370
387
  path: str,
371
388
  ) -> FileDiffResponse:
372
389
  """Return original and current file content for a git diff view."""
@@ -397,7 +414,7 @@ async def diff_file(
397
414
 
398
415
 
399
416
  @router.post('/write', response_model=FileOperationResponse)
400
- async def write_file(req: FileWriteRequest) -> FileOperationResponse:
417
+ def write_file(req: FileWriteRequest) -> FileOperationResponse:
401
418
  """Write a UTF-8 text file under the configured file root."""
402
419
  target, rel = _safe_path(req.path)
403
420
  if target.exists() and not target.is_file():
@@ -417,10 +434,10 @@ async def write_file(req: FileWriteRequest) -> FileOperationResponse:
417
434
 
418
435
 
419
436
  @router.post('/create', response_model=FileOperationResponse)
420
- async def create_path(req: FileCreateRequest) -> FileOperationResponse:
437
+ def create_path(req: FileCreateRequest) -> FileOperationResponse:
421
438
  """Create a file or folder under the configured file root."""
422
- target, rel = _safe_path(req.path)
423
- if target.exists():
439
+ target, rel = _safe_nofollow_path(req.path)
440
+ if target.exists() or target.is_symlink():
424
441
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Path already exists')
425
442
  try:
426
443
  if req.type == 'directory':
@@ -440,17 +457,17 @@ async def create_path(req: FileCreateRequest) -> FileOperationResponse:
440
457
 
441
458
 
442
459
  @router.post('/rename', response_model=FileOperationResponse)
443
- async def rename_path(req: FileRenameRequest) -> FileOperationResponse:
460
+ def rename_path(req: FileRenameRequest) -> FileOperationResponse:
444
461
  """Rename a file or folder under the configured file root."""
445
- target, _ = _safe_path(req.path)
446
- if not target.exists():
462
+ target, _ = _safe_nofollow_path(req.path)
463
+ if not target.exists() and not target.is_symlink():
447
464
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
448
465
  if '/' in req.new_name or '\\' in req.new_name or req.new_name in {'', '.', '..'}:
449
466
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid name')
450
- destination = (target.parent / req.new_name).resolve()
467
+ destination = target.parent / req.new_name
451
468
  if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
452
469
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
453
- if destination.exists():
470
+ if destination.exists() or destination.is_symlink():
454
471
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Destination already exists')
455
472
  try:
456
473
  target.rename(destination)
@@ -473,34 +490,37 @@ async def upload_file(
473
490
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
474
491
 
475
492
  safe_name = _safe_filename(filename)
476
- destination = (target_dir / safe_name).resolve()
493
+ destination = target_dir / safe_name
477
494
  if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
478
495
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
479
496
  if destination.exists() and destination.is_dir():
480
497
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='A folder with that name already exists')
481
- overwritten = destination.exists()
498
+ overwritten = destination.exists() or destination.is_symlink()
482
499
  if overwritten and not overwrite:
483
500
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='File already exists')
484
501
 
485
502
  temp_path = target_dir / f'.{safe_name}.{uuid.uuid4().hex}.uploading'
486
503
  size_bytes = 0
487
504
  try:
488
- with temp_path.open('wb') as output:
505
+ output = await asyncio.to_thread(temp_path.open, 'wb')
506
+ try:
489
507
  async for chunk in request.stream():
490
508
  size_bytes += len(chunk)
491
509
  if size_bytes > MAX_UPLOAD_BYTES:
492
510
  raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to upload')
493
- output.write(chunk)
494
- os.replace(temp_path, destination)
511
+ await asyncio.to_thread(output.write, chunk)
512
+ finally:
513
+ await asyncio.to_thread(output.close)
514
+ await asyncio.to_thread(os.replace, temp_path, destination)
495
515
  except HTTPException:
496
516
  try:
497
- temp_path.unlink(missing_ok=True)
517
+ await asyncio.to_thread(temp_path.unlink, missing_ok=True)
498
518
  except OSError:
499
519
  pass
500
520
  raise
501
521
  except OSError as exc:
502
522
  try:
503
- temp_path.unlink(missing_ok=True)
523
+ await asyncio.to_thread(temp_path.unlink, missing_ok=True)
504
524
  except OSError:
505
525
  pass
506
526
  raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
@@ -517,18 +537,20 @@ async def upload_file(
517
537
 
518
538
 
519
539
  @router.delete('', response_model=FileOperationResponse)
520
- async def delete_path(
540
+ def delete_path(
521
541
  path: str,
522
542
  recursive: bool = Query(False),
523
543
  ) -> FileOperationResponse:
524
544
  """Delete a file or folder under the configured file root."""
525
- target, rel = _safe_path(path)
545
+ target, rel = _safe_nofollow_path(path)
526
546
  if target == FILE_ROOT_PATH:
527
547
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Cannot delete file root')
528
- if not target.exists():
548
+ if not target.exists() and not target.is_symlink():
529
549
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
530
550
  try:
531
- if target.is_dir():
551
+ if target.is_symlink():
552
+ target.unlink()
553
+ elif target.is_dir():
532
554
  if recursive:
533
555
  shutil.rmtree(target)
534
556
  else:
@@ -343,6 +343,7 @@ async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateRes
343
343
  # ── container helper ───────────────────────────────────────────────────────────
344
344
 
345
345
  _container_start_status_by_repo: dict[str, dict] = {}
346
+ _container_start_tasks: set[asyncio.Task[None]] = set()
346
347
 
347
348
 
348
349
  def _prune_container_start_jobs() -> None:
@@ -424,6 +425,20 @@ async def _read_container_job_stream(stream: asyncio.StreamReader, job: dict) ->
424
425
  _update_container_job_from_line(job, line)
425
426
 
426
427
 
428
+ async def _stop_container_start_process(
429
+ proc: asyncio.subprocess.Process | None,
430
+ readers: list[asyncio.Task],
431
+ ) -> None:
432
+ """Stop a container-start subprocess and consume its reader tasks."""
433
+ if proc is not None and proc.returncode is None:
434
+ try:
435
+ proc.kill()
436
+ except ProcessLookupError:
437
+ pass
438
+ await proc.wait()
439
+ await asyncio.gather(*readers, return_exceptions=True)
440
+
441
+
427
442
  async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
428
443
  script = repo_path / 'docker' / 'container.sh'
429
444
  if not script.exists():
@@ -448,52 +463,58 @@ async def _run_container_start(repo_path: Path, name: str) -> None:
448
463
  job = _container_start_status_by_repo.get(name)
449
464
  if job is None:
450
465
  return
451
- script = repo_path / 'docker' / 'container.sh'
452
- if not script.exists():
453
- _set_container_job_status(
454
- job,
455
- running=False,
456
- success=False,
457
- error=f'container.sh not found at {script}',
458
- )
459
- return
460
-
461
- env = os.environ.copy()
462
- env['COMPOSE_PROGRESS'] = 'plain'
463
- env['COMPOSE_ANSI'] = 'never'
464
- proc = await asyncio.create_subprocess_exec(
465
- 'bash', str(script), 'start',
466
- cwd=str(repo_path / 'docker'),
467
- env=env,
468
- stdin=asyncio.subprocess.PIPE,
469
- stdout=asyncio.subprocess.PIPE,
470
- stderr=asyncio.subprocess.PIPE,
471
- )
472
- if proc.stdin is not None:
473
- proc.stdin.write(b'y\n')
474
- await proc.stdin.drain()
475
- proc.stdin.close()
476
-
477
- readers = [
478
- asyncio.create_task(_read_container_job_stream(proc.stdout, job)),
479
- asyncio.create_task(_read_container_job_stream(proc.stderr, job)),
480
- ]
466
+ proc: asyncio.subprocess.Process | None = None
467
+ readers: list[asyncio.Task] = []
481
468
  try:
469
+ script = repo_path / 'docker' / 'container.sh'
470
+ if not script.exists():
471
+ raise FileNotFoundError(f'container.sh not found at {script}')
472
+
473
+ env = os.environ.copy()
474
+ env['COMPOSE_PROGRESS'] = 'plain'
475
+ env['COMPOSE_ANSI'] = 'never'
476
+ proc = await asyncio.create_subprocess_exec(
477
+ 'bash', str(script), 'start',
478
+ cwd=str(repo_path / 'docker'),
479
+ env=env,
480
+ stdin=asyncio.subprocess.PIPE,
481
+ stdout=asyncio.subprocess.PIPE,
482
+ stderr=asyncio.subprocess.PIPE,
483
+ )
484
+ if proc.stdin is not None:
485
+ proc.stdin.write(b'y\n')
486
+ await proc.stdin.drain()
487
+ proc.stdin.close()
488
+
489
+ readers = [
490
+ asyncio.create_task(_read_container_job_stream(proc.stdout, job)),
491
+ asyncio.create_task(_read_container_job_stream(proc.stderr, job)),
492
+ ]
482
493
  await asyncio.wait_for(proc.wait(), timeout=CONTAINER_SCRIPT_TIMEOUT)
483
494
  await asyncio.gather(*readers)
484
495
  except asyncio.TimeoutError:
485
- proc.kill()
486
- await proc.wait()
487
- await asyncio.gather(*readers, return_exceptions=True)
488
- _append_container_job_output(job, 'Timeout waiting for container.sh')
496
+ error = 'Timeout waiting for container.sh'
497
+ await _stop_container_start_process(proc, readers)
498
+ _append_container_job_output(job, error)
499
+ _set_container_job_status(job, running=False, success=False, error=error)
500
+ return
501
+ except asyncio.CancelledError:
502
+ await _stop_container_start_process(proc, readers)
489
503
  _set_container_job_status(
490
504
  job,
491
505
  running=False,
492
506
  success=False,
493
- error='Timeout waiting for container.sh',
507
+ error='Container start operation was cancelled',
494
508
  )
509
+ raise
510
+ except Exception as exc:
511
+ await _stop_container_start_process(proc, readers)
512
+ error = str(exc) or type(exc).__name__
513
+ _append_container_job_output(job, error)
514
+ _set_container_job_status(job, running=False, success=False, error=error)
495
515
  return
496
516
 
517
+ assert proc is not None
497
518
  if proc.returncode == 0:
498
519
  _set_container_job_status(
499
520
  job,
@@ -569,7 +590,9 @@ async def start_repo_container(name: str) -> ContainerStartStatusResponse:
569
590
  return _build_container_start_status_response(existing)
570
591
 
571
592
  job = _new_container_start_status(name)
572
- asyncio.create_task(_run_container_start(repo_path, name))
593
+ task = asyncio.create_task(_run_container_start(repo_path, name))
594
+ _container_start_tasks.add(task)
595
+ task.add_done_callback(_container_start_tasks.discard)
573
596
  return _build_container_start_status_response(job)
574
597
 
575
598
 
@@ -21,21 +21,30 @@
21
21
  import time
22
22
  from pathlib import Path
23
23
 
24
+ from cyclo_host_agent.cpu_usage import CpuUsageSampler
24
25
  from cyclo_host_agent.models import (
25
26
  HostProcessesResponse,
26
27
  HostProcessInfo,
27
28
  HostSystemStatsResponse,
28
29
  )
29
- from fastapi import APIRouter, Query
30
+ from fastapi import APIRouter, HTTPException, Query
30
31
  import psutil
31
32
 
32
33
  router = APIRouter(prefix='/system', tags=['system'])
34
+ cpu_sampler = CpuUsageSampler()
33
35
 
34
36
  PROCESS_SAMPLE_INTERVAL_SECONDS = 0.2
35
37
  PROCESS_DEFAULT_LIMIT = 80
36
38
  EXTRA_STORAGE_MOUNT_PATHS = ('/mnt/ssd', '/data')
37
39
 
38
40
 
41
+ def _cpu_average() -> float:
42
+ value = cpu_sampler.average()
43
+ if value is None:
44
+ raise HTTPException(status_code=503, detail='CPU measurement is warming up')
45
+ return value
46
+
47
+
39
48
  def _temperature() -> float | None:
40
49
  try:
41
50
  sensors = psutil.sensors_temperatures()
@@ -84,7 +93,8 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
84
93
  except (psutil.NoSuchProcess, psutil.AccessDenied):
85
94
  continue
86
95
 
87
- overall_cpu_percent = psutil.cpu_percent(interval=PROCESS_SAMPLE_INTERVAL_SECONDS)
96
+ # Process usage keeps its short measurement window; total CPU uses the shared average.
97
+ time.sleep(PROCESS_SAMPLE_INTERVAL_SECONDS)
88
98
  mem = psutil.virtual_memory()
89
99
 
90
100
  processes: list[HostProcessInfo] = []
@@ -111,7 +121,7 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
111
121
 
112
122
  processes.sort(key=lambda item: item.cpu_percent, reverse=True)
113
123
  return HostProcessesResponse(
114
- cpu_percent=round(float(overall_cpu_percent), 1),
124
+ cpu_percent=_cpu_average(),
115
125
  memory_used_mb=int(mem.used // (1024 * 1024)),
116
126
  memory_total_mb=int(mem.total // (1024 * 1024)),
117
127
  processes=processes[:max(1, min(limit, 500))],
@@ -119,9 +129,9 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
119
129
 
120
130
 
121
131
  @router.get('/status', response_model=HostSystemStatsResponse)
122
- async def get_system_stats() -> HostSystemStatsResponse:
123
- """Return host CPU, memory, disk, and uptime stats."""
124
- cpu_percent = psutil.cpu_percent(interval=PROCESS_SAMPLE_INTERVAL_SECONDS)
132
+ def get_system_stats() -> HostSystemStatsResponse:
133
+ """Return host stats with CPU averaged over the latest three one-second samples."""
134
+ cpu_percent = _cpu_average()
125
135
  mem = psutil.virtual_memory()
126
136
  disk = psutil.disk_usage('/')
127
137
  extra_storage_path, extra_storage_disk = _extra_storage_disk_usage()
@@ -149,7 +159,7 @@ async def get_system_stats() -> HostSystemStatsResponse:
149
159
 
150
160
 
151
161
  @router.get('/processes', response_model=HostProcessesResponse)
152
- async def get_system_processes(
162
+ def get_system_processes(
153
163
  limit: int = Query(PROCESS_DEFAULT_LIMIT, ge=1, le=500),
154
164
  ) -> HostProcessesResponse:
155
165
  """Return host process CPU/memory usage sorted by CPU usage."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 1.1.0.dev2
3
+ Version: 1.1.0.dev4
4
4
  Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -38,6 +38,7 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
38
38
  - [What `cyclo_manager up` does](#what-cyclo_manager-up-does)
39
39
  - [Host agent (`cyclo_host_agent`)](#host-agent-cyclo_host_agent)
40
40
  - [Compose services](#compose-services)
41
+ - [Recording storage](#recording-storage)
41
42
  - [Configuration](#configuration)
42
43
  - [Agent sockets on the host](#agent-sockets-on-the-host)
43
44
  - [Environment variables](#environment-variables)
@@ -134,6 +135,10 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
134
135
  - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
135
136
  - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
136
137
  - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
138
+ - Provide host CPU/memory/disk statistics and process information. CPU summaries
139
+ share a background sampler's average of the latest three one-second samples.
140
+ - Serve the Files UI's browsing, filename search, upload, text editing, Git diff
141
+ and file/directory management operations under the configured workspace.
137
142
 
138
143
  `cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
139
144
 
@@ -145,10 +150,10 @@ Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set.
145
150
 
146
151
  Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml). All services use **`network_mode: host`**.
147
152
 
148
- | Compose service | Container name | `cyclo_manager up` | Image (example) |
153
+ | Compose service | Container name | `cyclo_manager up` | Image in this checkout |
149
154
  |-----------------|----------------|--------------------|-----------------|
150
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.0` |
151
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.0` |
155
+ | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.1.0` |
156
+ | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.1.0` |
152
157
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
153
158
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
154
159
 
@@ -156,6 +161,27 @@ Start optional containers from the UI or manually, e.g. `docker start zenoh_daem
156
161
 
157
162
  ---
158
163
 
164
+ ## Recording storage
165
+
166
+ The packaged stack and dev compose both mount:
167
+
168
+ ```yaml
169
+ - ${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags
170
+ ```
171
+
172
+ Compose resolves `${HOME}` on the host at invocation time. The API container uses
173
+ `RECORDINGS_DIR=/cyclo_manager_ros_bags`, which is also the server default when the
174
+ variable is absent. The host directory is independent of
175
+ `CYCLO_HOST_AGENT_WORKSPACE`; each recording stores its metadata and MCAP bag under
176
+ its generated ID. Container recreation, `down` and package updates preserve this
177
+ bind-mounted data. Applying a new mount requires container recreation.
178
+
179
+ Recording and playback run in the API container using `rosbag2_py` and the MCAP
180
+ storage plugin included in the manager image. They are not host-agent jobs.
181
+ See [Record & Play](../docs/record-play.md) for capture, repeat playback and stops.
182
+
183
+ ---
184
+
159
185
  ## Configuration
160
186
 
161
187
  The CLI always mounts the **bundled** config into the API container. There is **no** `-c` / `--config` flag on the pip CLI.
@@ -168,10 +194,13 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
168
194
 
169
195
  | Key | Description |
170
196
  |-----|-------------|
171
- | **`supported_robot_containers`** | Robot Docker container names that can open the System page (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
197
+ | **`supported_robot_containers`** | Containers eligible for System navigation and server-side motion profile detection (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
172
198
  | **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
173
199
 
174
- s6 **service names** are not listed in config; each in-container agent reports them at runtime.
200
+ s6 **service names** are defined in the manager's System/motion profiles, not in
201
+ this config. Motion detection checks the existing individual status endpoints for
202
+ `ai_worker_bringup` and `open_manipulator_bringup`, then reads `/run/robot_type`
203
+ inside the selected container through Docker. This needs no new s6-agent endpoint.
175
204
 
176
205
  ### Example (bundled default)
177
206
 
@@ -198,6 +227,7 @@ On the host, sockets typically live under:
198
227
  ```text
199
228
  /var/run/robotis/agent_sockets/
200
229
  ├── ai_worker/s6_agent.sock
230
+ ├── open_manipulator/s6_agent.sock
201
231
  ├── cyclo_intelligence/s6_agent.sock
202
232
  └── host/host_agent.sock ← created by cyclo_manager up
203
233
  ```
@@ -220,7 +250,11 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
220
250
  | **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
221
251
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
222
252
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
223
- | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
253
+ | **`HOME`** | Host environment used by Compose | Selects the host `${HOME}/cyclo_manager_ros_bags` bind-mount directory |
254
+ | **`RECORDINGS_DIR`** | Compose (`/cyclo_manager_ros_bags`) | Server-side recording directory inside the API container; same default without the variable |
255
+ | **`ROS_DOMAIN_ID`** | Manager image shell startup (`30`); not set by CLI | Must match the robot; the manager image configures it in `/root/.bashrc`, which the API startup sources |
256
+ | **`RMW_IMPLEMENTATION`** | Manager image shell startup (`rmw_zenoh_cpp`) | ROS middleware used by the manager; requires a reachable Zenoh router |
257
+ | **`NEXT_PUBLIC_API_URL`** | UI dev/build environment | Browser API address; production bundles embed it at build time, so a compose runtime value does not reconfigure a prebuilt UI |
224
258
 
225
259
  ---
226
260
 
@@ -234,6 +268,12 @@ With `network_mode: host` and default ports:
234
268
  | cyclo_manager API | http://127.0.0.1:8081 |
235
269
  | OpenAPI (Swagger) | http://127.0.0.1:8081/docs |
236
270
 
271
+ These loopback URLs apply on the robot host. From another device, use the robot's
272
+ hostname/IP and a browser-reachable API URL. Both compose files contain a UI
273
+ runtime value of `http://127.0.0.1:8081`; it takes effect in development, while a
274
+ production bundle uses its build-time value or hostname fallback. See the
275
+ [UI API address guide](../cyclo_manager_ui/README_UI.md#api-address).
276
+
237
277
  ---
238
278
 
239
279
  ## Custom config and development
@@ -247,6 +287,11 @@ For development (local API/UI source, custom `config.yml`, hot reload):
247
287
  docker compose -f docker-compose.dev.yml up
248
288
  ```
249
289
 
290
+ The dev compose command starts all defined services and does not install the host
291
+ agent. API/UI source mounts do not update the Python code used by the separately
292
+ installed `cyclo_host_agent.service`; install the updated CLI package and refresh
293
+ that service when testing host-agent changes.
294
+
250
295
  See the [root README](../README.md#development).
251
296
 
252
297
  ---
@@ -259,7 +304,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
259
304
  |---------|---------|
260
305
  | **fastapi** | `cyclo_host_agent` HTTP API |
261
306
  | **uvicorn** | `cyclo_host_agent` server |
262
- | **psutil** | Declared package dependency for host/system utility support |
307
+ | **psutil** | Host CPU sampler, memory/disk statistics and process inspection |
263
308
 
264
309
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
265
310
 
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  setup.cfg
4
4
  cyclo_host_agent/__init__.py
5
+ cyclo_host_agent/cpu_usage.py
5
6
  cyclo_host_agent/main.py
6
7
  cyclo_host_agent/models.py
7
8
  cyclo_host_agent/routers/__init__.py
@@ -18,4 +19,5 @@ cyclo_manager.egg-info/top_level.txt
18
19
  cyclo_manager_cli/__init__.py
19
20
  cyclo_manager_cli/cli.py
20
21
  cyclo_manager_cli/config/config.yml
21
- cyclo_manager_cli/docker/docker-compose.yml
22
+ cyclo_manager_cli/docker/docker-compose.yml
23
+ tests/test_cpu_usage.py
@@ -18,4 +18,4 @@
18
18
 
19
19
  """cyclo_manager CLI - pip-installable launcher for cyclo_manager server and UI containers."""
20
20
 
21
- __version__ = '1.0.0'
21
+ __version__ = '1.1.0'
@@ -21,8 +21,10 @@ services:
21
21
  - ${CYCLO_MANAGER_CONFIG_FILE}:/app/config.yml # For hot-reload on config changes
22
22
  - /var/run/docker.sock:/var/run/docker.sock # For Docker API access
23
23
  - /:/host_root:ro # For host disk usage and OS info
24
+ - ${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags
24
25
  environment:
25
26
  - CONFIG_FILE=/app/config.yml
27
+ - RECORDINGS_DIR=/cyclo_manager_ros_bags
26
28
  - HOST_HOSTNAME=${HOSTNAME}
27
29
 
28
30
  ui:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cyclo-manager"
7
- version = "1.1.0.dev2"
7
+ version = "1.1.0.dev4"
8
8
  description = "cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """Regression tests for continuous CPU sampling and shared API summaries."""
20
+
21
+ from types import SimpleNamespace
22
+ import threading
23
+ import time
24
+ import unittest
25
+ from unittest.mock import Mock, call, patch
26
+
27
+ from cyclo_host_agent.cpu_usage import CpuUsageSampler
28
+ from cyclo_host_agent.main import app, lifespan
29
+ from cyclo_host_agent.routers import system_stats
30
+ from fastapi import HTTPException
31
+
32
+
33
+ class CpuUsageTests(unittest.TestCase):
34
+ """Check measurement windows, startup behavior, and sampling ownership."""
35
+
36
+ def test_rolling_window_discards_prime_and_oldest_sample(self):
37
+ """Average available samples at startup and only the last three afterward."""
38
+ sampler = CpuUsageSampler()
39
+ observed = []
40
+
41
+ def next_interval(interval):
42
+ self.assertEqual(interval, 1.0)
43
+ observed.append(sampler.average())
44
+ if len(observed) == 5:
45
+ sampler._stop.set()
46
+ return True
47
+ return False
48
+
49
+ with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
50
+ side_effect=[99.0, 10.0, 20.0, 60.0, 90.0]) as cpu:
51
+ with patch.object(sampler._stop, 'wait', side_effect=next_interval):
52
+ sampler._run()
53
+ self.assertEqual(observed, [None, 10.0, 15.0, 30.0, 56.7])
54
+ self.assertEqual(cpu.call_args_list, [call(interval=None)] * 5)
55
+
56
+ def test_background_sampling_is_independent_of_readers(self):
57
+ """Prime and sample on one thread, even without API requests."""
58
+ sampler = CpuUsageSampler(interval=0.01)
59
+ thread_ids = []
60
+
61
+ def cpu_percent(interval):
62
+ thread_ids.append(threading.get_ident())
63
+ return 30.0
64
+
65
+ with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
66
+ side_effect=cpu_percent):
67
+ sampler.start()
68
+ worker = sampler._thread
69
+ try:
70
+ sampler.start()
71
+ self.assertIs(sampler._thread, worker)
72
+ deadline = time.monotonic() + 2
73
+ while sampler.average() is None and time.monotonic() < deadline:
74
+ time.sleep(0.005)
75
+ self.assertEqual(sampler.average(), 30.0)
76
+ finally:
77
+ sampler.stop()
78
+ self.assertFalse(worker.is_alive())
79
+ count = len(thread_ids)
80
+ for _ in range(50):
81
+ self.assertEqual(sampler.average(), 30.0)
82
+ self.assertEqual(len(thread_ids), count)
83
+ self.assertGreaterEqual(count, 2)
84
+ self.assertEqual(len(set(thread_ids)), 1)
85
+ self.assertNotEqual(thread_ids[0], threading.get_ident())
86
+ sampler._interval = 1.0
87
+ sampler.start()
88
+ try:
89
+ self.assertIsNone(sampler.average())
90
+ finally:
91
+ sampler.stop()
92
+
93
+ def test_warmup_does_not_report_a_fake_zero(self):
94
+ """Return a retryable response before a complete sample exists."""
95
+ with patch.object(system_stats, 'cpu_sampler', CpuUsageSampler()):
96
+ with self.assertRaises(HTTPException) as result:
97
+ system_stats.get_system_stats()
98
+ self.assertEqual(result.exception.status_code, 503)
99
+
100
+ def test_sampling_failure_clears_history_and_primes_again(self):
101
+ """Do not keep old CPU values when a measurement fails."""
102
+ sampler = CpuUsageSampler()
103
+ observed = []
104
+
105
+ def next_interval(interval):
106
+ observed.append(sampler.average())
107
+ if len(observed) == 5:
108
+ sampler._stop.set()
109
+ return True
110
+ return False
111
+
112
+ with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
113
+ side_effect=[99.0, 10.0, OSError('CPU read failed'), 99.0, 60.0]), \
114
+ patch('cyclo_host_agent.cpu_usage.logger.exception') as log, \
115
+ patch.object(sampler._stop, 'wait', side_effect=next_interval):
116
+ sampler._run()
117
+ self.assertEqual(observed, [None, 10.0, None, None, 60.0])
118
+ log.assert_called_once()
119
+
120
+ def test_status_and_process_summary_read_the_same_average(self):
121
+ """Total CPU is shared while process rows retain their own measurements."""
122
+ process = Mock()
123
+ process.as_dict.return_value = {
124
+ 'pid': 42, 'username': 'robot', 'cpu_percent': 80.0,
125
+ 'memory_percent': 1.0, 'memory_info': SimpleNamespace(rss=1024),
126
+ 'cmdline': ['robot-node'], 'name': 'robot-node',
127
+ }
128
+ with patch.object(system_stats.cpu_sampler, 'average', return_value=12.3), \
129
+ patch.object(system_stats.psutil, 'cpu_percent',
130
+ side_effect=AssertionError('API must not resample total CPU')), \
131
+ patch.object(system_stats.psutil, 'virtual_memory',
132
+ return_value=SimpleNamespace(used=1024**3, total=8*1024**3)), \
133
+ patch.object(system_stats.psutil, 'disk_usage',
134
+ return_value=SimpleNamespace(used=10*1024**3, total=100*1024**3)), \
135
+ patch.object(system_stats, '_extra_storage_disk_usage', return_value=(None, None)), \
136
+ patch.object(system_stats, '_temperature', return_value=None), \
137
+ patch.object(system_stats.psutil, 'process_iter', return_value=[process]), \
138
+ patch.object(system_stats.psutil, 'cpu_count', return_value=4), \
139
+ patch.object(system_stats.time, 'sleep') as sleep:
140
+ status = system_stats.get_system_stats()
141
+ processes = system_stats._sample_processes()
142
+ self.assertEqual(status.cpu_percent, 12.3)
143
+ self.assertEqual(processes.cpu_percent, status.cpu_percent)
144
+ self.assertEqual(processes.processes[0].cpu_percent, 20.0)
145
+ process.cpu_percent.assert_called_once_with(None)
146
+ sleep.assert_called_once_with(0.2)
147
+
148
+
149
+ class CpuLifespanTests(unittest.IsolatedAsyncioTestCase):
150
+ """Check sampler cleanup when the API exits."""
151
+
152
+ async def test_lifespan_stops_sampler_even_on_failure(self):
153
+ """Stop the background worker on both normal and exceptional shutdown."""
154
+ sampler = Mock()
155
+ with patch.object(system_stats, 'cpu_sampler', sampler):
156
+ async with lifespan(app):
157
+ sampler.start.assert_called_once_with()
158
+ sampler.stop.assert_not_called()
159
+ sampler.stop.assert_called_once_with()
160
+ sampler.reset_mock()
161
+ with self.assertRaises(RuntimeError):
162
+ async with lifespan(app):
163
+ raise RuntimeError('shutdown')
164
+ sampler.start.assert_called_once_with()
165
+ sampler.stop.assert_called_once_with()
166
+
167
+
168
+ if __name__ == '__main__':
169
+ unittest.main()