cyclo-manager 1.0.0.dev10__tar.gz → 1.1.0.dev1__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 (22) hide show
  1. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/PKG-INFO +17 -9
  2. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/README.md +16 -8
  3. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/main.py +3 -1
  4. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/models.py +106 -0
  5. cyclo_manager-1.1.0.dev1/cyclo_host_agent/routers/files.py +216 -0
  6. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/routers/repos.py +178 -4
  7. cyclo_manager-1.1.0.dev1/cyclo_host_agent/routers/system_stats.py +156 -0
  8. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/PKG-INFO +17 -9
  9. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/SOURCES.txt +2 -0
  10. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/docker/docker-compose.yml +2 -2
  11. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/pyproject.toml +1 -1
  12. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/__init__.py +0 -0
  13. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/routers/__init__.py +0 -0
  14. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/routers/update.py +0 -0
  15. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  16. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/entry_points.txt +0 -0
  17. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/requires.txt +0 -0
  18. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/top_level.txt +0 -0
  19. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/__init__.py +0 -0
  20. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/cli.py +0 -0
  21. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/config/config.yml +0 -0
  22. {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 1.0.0.dev10
3
+ Version: 1.1.0.dev1
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
@@ -18,7 +18,7 @@ Requires-Dist: ruff; extra == "dev"
18
18
  **Console commands:** `cyclo_manager` (underscore), `cyclo-manager` (hyphen)
19
19
  **Host agent:** `cyclo_host_agent` (installed by the package, run via systemd)
20
20
 
21
- This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It:
21
+ This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It is intended for production robots that run the pre-built Docker images. It:
22
22
 
23
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
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).
@@ -50,11 +50,12 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
50
50
 
51
51
  ## Prerequisites
52
52
 
53
- - **Docker** with **Compose v2** (`docker compose` — the legacy `docker-compose` binary alone is not enough)
53
+ - **Docker** with **Compose v2** (`docker compose`; the legacy `docker-compose` binary alone is not enough)
54
54
  - **Python 3.10+**
55
+ - **systemd**: required for the managed `cyclo_host_agent.service`
55
56
  - **`sudo`** — required on normal-user hosts to install the host agent systemd unit, socket directory permissions, and sudoers rules
56
57
  - For **`cyclo_manager update`:** **`pip`** or **`pip3`** on `PATH`
57
- - **Root shell handling** — root is rejected when a normal login user exists. On root-only devices, the host agent is installed as root.
58
+ - **Root shell handling** — `cyclo_manager up` rejects direct root execution when a normal login user exists. On root-only devices, the host agent is installed as root.
58
59
  - **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
 
60
61
  ---
@@ -94,6 +95,8 @@ python3 -m cyclo_manager_cli.cli up
94
95
  | `cyclo_manager update --no-pull` | Same as `update`, but skip image pull (use locally cached images). |
95
96
  | `cyclo_manager --help` | Subcommand overview |
96
97
 
98
+ The CLI also has an internal `refresh-host-agent` subcommand. It is used by the sudoers rule and should normally not be invoked by users directly.
99
+
97
100
  **`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`.
98
101
 
99
102
  ---
@@ -120,18 +123,21 @@ The pip package installs a small **FastAPI** server that listens on a **Unix dom
120
123
  |------|------|
121
124
  | Socket | `/var/run/robotis/agent_sockets/host/host_agent.sock` |
122
125
  | systemd unit | `cyclo_host_agent.service` |
123
- | sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for UI updates; skipped on root-only devices) |
126
+ | sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for package-update refresh; skipped on root-only devices) |
124
127
 
125
128
  The API container reaches it at `/agents/host/host_agent.sock` (see bundled `config.yml`).
126
129
 
127
130
  **Responsibilities:**
128
131
 
129
- - List and update **`~/ROBOTIS-GIT/*`** git repositories (Version Management in the UI)
130
- - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`
132
+ - List managed **ROBOTIS-GIT** git repositories in the configured workspace.
133
+ - Check update availability from remote tags and local `package.xml` versions.
134
+ - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
135
+ - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
136
+ - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
131
137
 
132
138
  `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.
133
139
 
134
- Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically.
140
+ Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
135
141
 
136
142
  ---
137
143
 
@@ -211,6 +217,8 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
211
217
  | Variable | Set by | Purpose |
212
218
  |----------|--------|---------|
213
219
  | **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
220
+ | **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
221
+ | **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
214
222
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
215
223
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
216
224
  | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
@@ -252,7 +260,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
252
260
  |---------|---------|
253
261
  | **fastapi** | `cyclo_host_agent` HTTP API |
254
262
  | **uvicorn** | `cyclo_host_agent` server |
255
- | **psutil** | Host agent / system utilities |
263
+ | **psutil** | Declared package dependency for host/system utility support |
256
264
 
257
265
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
258
266
 
@@ -4,7 +4,7 @@
4
4
  **Console commands:** `cyclo_manager` (underscore), `cyclo-manager` (hyphen)
5
5
  **Host agent:** `cyclo_host_agent` (installed by the package, run via systemd)
6
6
 
7
- This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It:
7
+ This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It is intended for production robots that run the pre-built Docker images. It:
8
8
 
9
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
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).
@@ -36,11 +36,12 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
36
36
 
37
37
  ## Prerequisites
38
38
 
39
- - **Docker** with **Compose v2** (`docker compose` — the legacy `docker-compose` binary alone is not enough)
39
+ - **Docker** with **Compose v2** (`docker compose`; the legacy `docker-compose` binary alone is not enough)
40
40
  - **Python 3.10+**
41
+ - **systemd**: required for the managed `cyclo_host_agent.service`
41
42
  - **`sudo`** — required on normal-user hosts to install the host agent systemd unit, socket directory permissions, and sudoers rules
42
43
  - For **`cyclo_manager update`:** **`pip`** or **`pip3`** on `PATH`
43
- - **Root shell handling** — root is rejected when a normal login user exists. On root-only devices, the host agent is installed as root.
44
+ - **Root shell handling** — `cyclo_manager up` rejects direct root execution when a normal login user exists. On root-only devices, the host agent is installed as root.
44
45
  - **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
 
46
47
  ---
@@ -80,6 +81,8 @@ python3 -m cyclo_manager_cli.cli up
80
81
  | `cyclo_manager update --no-pull` | Same as `update`, but skip image pull (use locally cached images). |
81
82
  | `cyclo_manager --help` | Subcommand overview |
82
83
 
84
+ The CLI also has an internal `refresh-host-agent` subcommand. It is used by the sudoers rule and should normally not be invoked by users directly.
85
+
83
86
  **`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`.
84
87
 
85
88
  ---
@@ -106,18 +109,21 @@ The pip package installs a small **FastAPI** server that listens on a **Unix dom
106
109
  |------|------|
107
110
  | Socket | `/var/run/robotis/agent_sockets/host/host_agent.sock` |
108
111
  | systemd unit | `cyclo_host_agent.service` |
109
- | sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for UI updates; skipped on root-only devices) |
112
+ | sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for package-update refresh; skipped on root-only devices) |
110
113
 
111
114
  The API container reaches it at `/agents/host/host_agent.sock` (see bundled `config.yml`).
112
115
 
113
116
  **Responsibilities:**
114
117
 
115
- - List and update **`~/ROBOTIS-GIT/*`** git repositories (Version Management in the UI)
116
- - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`
118
+ - List managed **ROBOTIS-GIT** git repositories in the configured workspace.
119
+ - Check update availability from remote tags and local `package.xml` versions.
120
+ - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
121
+ - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
122
+ - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
117
123
 
118
124
  `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.
119
125
 
120
- Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically.
126
+ Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
121
127
 
122
128
  ---
123
129
 
@@ -197,6 +203,8 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
197
203
  | Variable | Set by | Purpose |
198
204
  |----------|--------|---------|
199
205
  | **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
206
+ | **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
207
+ | **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
200
208
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
201
209
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
202
210
  | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
@@ -238,7 +246,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
238
246
  |---------|---------|
239
247
  | **fastapi** | `cyclo_host_agent` HTTP API |
240
248
  | **uvicorn** | `cyclo_host_agent` server |
241
- | **psutil** | Host agent / system utilities |
249
+ | **psutil** | Declared package dependency for host/system utility support |
242
250
 
243
251
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
244
252
 
@@ -21,7 +21,7 @@
21
21
  import logging
22
22
  from pathlib import Path
23
23
 
24
- from cyclo_host_agent.routers import repos, update
24
+ from cyclo_host_agent.routers import files, repos, system_stats, update
25
25
  from cyclo_manager_cli import __version__
26
26
  from fastapi import FastAPI
27
27
  import uvicorn
@@ -41,6 +41,8 @@ app = FastAPI(
41
41
  )
42
42
 
43
43
  app.include_router(repos.router)
44
+ app.include_router(files.router)
45
+ app.include_router(system_stats.router)
44
46
  app.include_router(update.router)
45
47
 
46
48
 
@@ -103,3 +103,109 @@ class ContainerScriptResponse(BaseModel):
103
103
  action: str
104
104
  success: bool
105
105
  output: str
106
+
107
+
108
+ class ContainerStartStatusResponse(BaseModel):
109
+ """Current output/status for a container start operation."""
110
+
111
+ running: bool
112
+ output: str
113
+ success: bool | None = None
114
+ error: str = ''
115
+
116
+
117
+ class HostSystemStatsResponse(BaseModel):
118
+ """Host CPU/memory/disk/uptime/temperature status."""
119
+
120
+ cpu_percent: float
121
+ memory_used_mb: int
122
+ memory_total_mb: int
123
+ disk_used_gb: float
124
+ disk_total_gb: float
125
+ ssd_used_gb: float | None = None
126
+ ssd_total_gb: float | None = None
127
+ ssd_mount_path: str | None = None
128
+ uptime_seconds: int
129
+ temperature_celsius: float | None = None
130
+
131
+
132
+ class HostProcessInfo(BaseModel):
133
+ """Single host process CPU/memory usage sample."""
134
+
135
+ pid: int
136
+ user: str
137
+ cpu_percent: float
138
+ memory_percent: float
139
+ rss_kb: int | None = None
140
+ command: str
141
+
142
+
143
+ class HostProcessesResponse(BaseModel):
144
+ """Host process list with aggregate resource summary."""
145
+
146
+ cpu_percent: float
147
+ memory_used_mb: int
148
+ memory_total_mb: int
149
+ processes: list[HostProcessInfo]
150
+
151
+
152
+ class FileTreeEntry(BaseModel):
153
+ """Single file browser entry."""
154
+
155
+ name: str
156
+ path: str
157
+ type: str
158
+ size: int | None = None
159
+ modified: float | None = None
160
+ readonly: bool = False
161
+ hidden: bool = False
162
+ symlink: bool = False
163
+
164
+
165
+ class FileTreeResponse(BaseModel):
166
+ """Response for GET /files/tree."""
167
+
168
+ root_path: str
169
+ path: str
170
+ entries: list[FileTreeEntry]
171
+
172
+
173
+ class FileReadResponse(BaseModel):
174
+ """Response for GET /files/read."""
175
+
176
+ path: str
177
+ content: str
178
+ size: int
179
+ modified: float
180
+ readonly: bool = False
181
+
182
+
183
+ class FileWriteRequest(BaseModel):
184
+ """Request body for POST /files/write."""
185
+
186
+ path: str
187
+ content: str
188
+ expected_modified: float | None = None
189
+
190
+
191
+ class FileCreateRequest(BaseModel):
192
+ """Request body for POST /files/create."""
193
+
194
+ path: str
195
+ type: str
196
+ content: str = ''
197
+
198
+
199
+ class FileRenameRequest(BaseModel):
200
+ """Request body for POST /files/rename."""
201
+
202
+ path: str
203
+ new_name: str
204
+
205
+
206
+ class FileOperationResponse(BaseModel):
207
+ """Generic file operation response."""
208
+
209
+ path: str
210
+ success: bool
211
+ message: str
@@ -0,0 +1,216 @@
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
+ """Host file browser and editor endpoints."""
20
+
21
+ import os
22
+ import shutil
23
+ from pathlib import Path
24
+
25
+ from cyclo_host_agent.models import (
26
+ FileCreateRequest,
27
+ FileOperationResponse,
28
+ FileReadResponse,
29
+ FileRenameRequest,
30
+ FileTreeEntry,
31
+ FileTreeResponse,
32
+ FileWriteRequest,
33
+ )
34
+ from fastapi import APIRouter, HTTPException, Query, status
35
+
36
+ router = APIRouter(prefix='/files', tags=['files'])
37
+
38
+ MAX_READ_BYTES = 2 * 1024 * 1024
39
+ MAX_WRITE_BYTES = 4 * 1024 * 1024
40
+
41
+
42
+ def _resolve_file_root() -> Path:
43
+ configured_root = os.environ.get('CYCLO_HOST_AGENT_FILE_ROOT')
44
+ if configured_root:
45
+ return Path(configured_root).expanduser().resolve()
46
+ sudo_user = os.environ.get('SUDO_USER')
47
+ if sudo_user:
48
+ return Path(f'/home/{sudo_user}').resolve()
49
+ return Path.home().resolve()
50
+
51
+
52
+ FILE_ROOT_PATH = _resolve_file_root()
53
+
54
+
55
+ def _safe_path(relative_path: str = '') -> tuple[Path, str]:
56
+ target = (FILE_ROOT_PATH / relative_path).resolve()
57
+ if target != FILE_ROOT_PATH and FILE_ROOT_PATH not in target.parents:
58
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
59
+ rel = '' if target == FILE_ROOT_PATH else target.relative_to(FILE_ROOT_PATH).as_posix()
60
+ return target, rel
61
+
62
+
63
+ def _is_binary(path: Path) -> bool:
64
+ try:
65
+ return b'\x00' in path.read_bytes()[:8192]
66
+ except OSError:
67
+ return True
68
+
69
+
70
+ def _entry(path: Path) -> FileTreeEntry:
71
+ stat = path.lstat()
72
+ return FileTreeEntry(
73
+ name=path.name,
74
+ path='' if path == FILE_ROOT_PATH else path.relative_to(FILE_ROOT_PATH).as_posix(),
75
+ type='directory' if path.is_dir() else 'file',
76
+ size=None if path.is_dir() else stat.st_size,
77
+ modified=stat.st_mtime,
78
+ readonly=not os.access(path, os.W_OK),
79
+ hidden=path.name.startswith('.'),
80
+ symlink=path.is_symlink(),
81
+ )
82
+
83
+
84
+ @router.get('/tree', response_model=FileTreeResponse)
85
+ async def list_directory(
86
+ path: str = '',
87
+ show_hidden: bool = Query(False),
88
+ ) -> FileTreeResponse:
89
+ """Return one directory level under the configured file root."""
90
+ target, rel = _safe_path(path)
91
+ if not target.is_dir():
92
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
93
+
94
+ entries: list[FileTreeEntry] = []
95
+ for child in sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
96
+ if not show_hidden and child.name.startswith('.'):
97
+ continue
98
+ try:
99
+ entries.append(_entry(child))
100
+ except OSError:
101
+ continue
102
+ return FileTreeResponse(root_path=str(FILE_ROOT_PATH), path=rel, entries=entries)
103
+
104
+
105
+ @router.get('/read', response_model=FileReadResponse)
106
+ async def read_file(
107
+ path: str,
108
+ ) -> FileReadResponse:
109
+ """Read a UTF-8 text file under the configured file root."""
110
+ target, rel = _safe_path(path)
111
+ if not target.is_file():
112
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
113
+ stat = target.stat()
114
+ if stat.st_size > MAX_READ_BYTES:
115
+ raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to edit')
116
+ if _is_binary(target):
117
+ raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail='Binary files are not editable')
118
+ try:
119
+ content = target.read_text(encoding='utf-8', errors='replace')
120
+ except OSError as exc:
121
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
122
+ return FileReadResponse(
123
+ path=rel,
124
+ content=content,
125
+ size=stat.st_size,
126
+ modified=stat.st_mtime,
127
+ readonly=not os.access(target, os.W_OK),
128
+ )
129
+
130
+
131
+ @router.post('/write', response_model=FileOperationResponse)
132
+ async def write_file(req: FileWriteRequest) -> FileOperationResponse:
133
+ """Write a UTF-8 text file under the configured file root."""
134
+ target, rel = _safe_path(req.path)
135
+ if target.exists() and not target.is_file():
136
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
137
+ if len(req.content.encode('utf-8')) > MAX_WRITE_BYTES:
138
+ raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Content is too large')
139
+ if req.expected_modified is not None and target.exists():
140
+ current_modified = target.stat().st_mtime
141
+ if abs(current_modified - req.expected_modified) > 0.001:
142
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='File changed on disk')
143
+ try:
144
+ target.parent.mkdir(parents=True, exist_ok=True)
145
+ target.write_text(req.content, encoding='utf-8')
146
+ except OSError as exc:
147
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
148
+ return FileOperationResponse(path=rel, success=True, message='Saved')
149
+
150
+
151
+ @router.post('/create', response_model=FileOperationResponse)
152
+ async def create_path(req: FileCreateRequest) -> FileOperationResponse:
153
+ """Create a file or folder under the configured file root."""
154
+ target, rel = _safe_path(req.path)
155
+ if target.exists():
156
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Path already exists')
157
+ try:
158
+ if req.type == 'directory':
159
+ target.mkdir(parents=True)
160
+ message = 'Folder created'
161
+ elif req.type == 'file':
162
+ if len(req.content.encode('utf-8')) > MAX_WRITE_BYTES:
163
+ raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Content is too large')
164
+ target.parent.mkdir(parents=True, exist_ok=True)
165
+ target.write_text(req.content, encoding='utf-8')
166
+ message = 'File created'
167
+ else:
168
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Type must be file or directory')
169
+ except OSError as exc:
170
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
171
+ return FileOperationResponse(path=rel, success=True, message=message)
172
+
173
+
174
+ @router.post('/rename', response_model=FileOperationResponse)
175
+ async def rename_path(req: FileRenameRequest) -> FileOperationResponse:
176
+ """Rename a file or folder under the configured file root."""
177
+ target, _ = _safe_path(req.path)
178
+ if not target.exists():
179
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
180
+ if '/' in req.new_name or '\\' in req.new_name or req.new_name in {'', '.', '..'}:
181
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid name')
182
+ destination = (target.parent / req.new_name).resolve()
183
+ if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
184
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
185
+ if destination.exists():
186
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Destination already exists')
187
+ try:
188
+ target.rename(destination)
189
+ except OSError as exc:
190
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
191
+ rel = '' if destination == FILE_ROOT_PATH else destination.relative_to(FILE_ROOT_PATH).as_posix()
192
+ return FileOperationResponse(path=rel, success=True, message='Renamed')
193
+
194
+
195
+ @router.delete('', response_model=FileOperationResponse)
196
+ async def delete_path(
197
+ path: str,
198
+ recursive: bool = Query(False),
199
+ ) -> FileOperationResponse:
200
+ """Delete a file or folder under the configured file root."""
201
+ target, rel = _safe_path(path)
202
+ if target == FILE_ROOT_PATH:
203
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Cannot delete file root')
204
+ if not target.exists():
205
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
206
+ try:
207
+ if target.is_dir():
208
+ if recursive:
209
+ shutil.rmtree(target)
210
+ else:
211
+ target.rmdir()
212
+ else:
213
+ target.unlink()
214
+ except OSError as exc:
215
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
216
+ return FileOperationResponse(path=rel, success=True, message='Deleted')
@@ -22,9 +22,11 @@ import asyncio
22
22
  import os
23
23
  from pathlib import Path
24
24
  import re
25
+ import time
25
26
  from typing import Optional
26
27
 
27
28
  from cyclo_host_agent.models import (
29
+ ContainerStartStatusResponse,
28
30
  ContainerScriptResponse,
29
31
  FileChange,
30
32
  RepoBranchCheckResponse,
@@ -42,6 +44,9 @@ router = APIRouter(prefix='/repos', tags=['repos'])
42
44
 
43
45
  GIT_TIMEOUT = 120.0
44
46
  GIT_REMOTE_TIMEOUT = 5.0
47
+ CONTAINER_SCRIPT_TIMEOUT = 600.0
48
+ CONTAINER_JOB_RETENTION_SECONDS = 3600.0
49
+ CONTAINER_JOB_OUTPUT_LIMIT = 40000
45
50
 
46
51
 
47
52
  def _resolve_workspace() -> Path:
@@ -337,6 +342,88 @@ async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateRes
337
342
 
338
343
  # ── container helper ───────────────────────────────────────────────────────────
339
344
 
345
+ _container_start_status_by_repo: dict[str, dict] = {}
346
+
347
+
348
+ def _prune_container_start_jobs() -> None:
349
+ now = time.time()
350
+ stale = [
351
+ name
352
+ for name, status_entry in _container_start_status_by_repo.items()
353
+ if now - float(status_entry.get('updated_at') or 0) > CONTAINER_JOB_RETENTION_SECONDS
354
+ ]
355
+ for name in stale:
356
+ _container_start_status_by_repo.pop(name, None)
357
+
358
+
359
+ def _new_container_start_status(name: str) -> dict:
360
+ _prune_container_start_jobs()
361
+ now = time.time()
362
+ status_entry = {
363
+ 'name': name,
364
+ 'running': True,
365
+ 'output': '',
366
+ 'success': None,
367
+ 'error': '',
368
+ 'started_at': now,
369
+ 'updated_at': now,
370
+ }
371
+ _container_start_status_by_repo[name] = status_entry
372
+ return status_entry
373
+
374
+
375
+ def _append_container_job_output(job: dict, line: str) -> None:
376
+ if not line:
377
+ return
378
+ output = f"{job.get('output') or ''}{line}\n"
379
+ if len(output) > CONTAINER_JOB_OUTPUT_LIMIT:
380
+ output = output[-CONTAINER_JOB_OUTPUT_LIMIT:]
381
+ job['output'] = output
382
+ job['updated_at'] = time.time()
383
+
384
+
385
+ def _set_container_job_status(
386
+ job: dict,
387
+ *,
388
+ running: Optional[bool] = None,
389
+ success: Optional[bool] = None,
390
+ error: Optional[str] = None,
391
+ ) -> None:
392
+ if running is not None:
393
+ job['running'] = running
394
+ if success is not None:
395
+ job['success'] = success
396
+ if error is not None:
397
+ job['error'] = error
398
+ job['updated_at'] = time.time()
399
+
400
+
401
+ def _update_container_job_from_line(job: dict, line: str) -> None:
402
+ line = line.strip()
403
+ if not line:
404
+ return
405
+ _append_container_job_output(job, line)
406
+
407
+
408
+ def _build_container_start_status_response(job: dict) -> ContainerStartStatusResponse:
409
+ return ContainerStartStatusResponse(
410
+ running=bool(job.get('running')),
411
+ output=job.get('output') or '',
412
+ success=job.get('success'),
413
+ error=job.get('error') or '',
414
+ )
415
+
416
+
417
+ async def _read_container_job_stream(stream: asyncio.StreamReader, job: dict) -> None:
418
+ while True:
419
+ raw = await stream.readline()
420
+ if not raw:
421
+ break
422
+ text = raw.decode(errors='replace').replace('\r', '\n')
423
+ for line in text.splitlines():
424
+ _update_container_job_from_line(job, line)
425
+
426
+
340
427
  async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
341
428
  script = repo_path / 'docker' / 'container.sh'
342
429
  if not script.exists():
@@ -357,6 +444,71 @@ async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
357
444
  return proc.returncode == 0, (stdout.decode() + stderr.decode()).strip()
358
445
 
359
446
 
447
+ async def _run_container_start(repo_path: Path, name: str) -> None:
448
+ job = _container_start_status_by_repo.get(name)
449
+ if job is None:
450
+ 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
+ ]
481
+ try:
482
+ await asyncio.wait_for(proc.wait(), timeout=CONTAINER_SCRIPT_TIMEOUT)
483
+ await asyncio.gather(*readers)
484
+ 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')
489
+ _set_container_job_status(
490
+ job,
491
+ running=False,
492
+ success=False,
493
+ error='Timeout waiting for container.sh',
494
+ )
495
+ return
496
+
497
+ if proc.returncode == 0:
498
+ _set_container_job_status(
499
+ job,
500
+ running=False,
501
+ success=True,
502
+ )
503
+ else:
504
+ _set_container_job_status(
505
+ job,
506
+ running=False,
507
+ success=False,
508
+ error=f'container.sh exited with code {proc.returncode}',
509
+ )
510
+
511
+
360
512
  # ── endpoints ──────────────────────────────────────────────────────────────────
361
513
 
362
514
  @router.get('/updates', response_model=RepoUpdatesResponse)
@@ -409,11 +561,33 @@ async def stop_repo_container(name: str) -> ContainerScriptResponse:
409
561
  return ContainerScriptResponse(name=name, action='stop', success=success, output=output)
410
562
 
411
563
 
412
- @router.post('/{name}/container/start', response_model=ContainerScriptResponse)
413
- async def start_repo_container(name: str) -> ContainerScriptResponse:
564
+ @router.post('/{name}/container/start', response_model=ContainerStartStatusResponse)
565
+ async def start_repo_container(name: str) -> ContainerStartStatusResponse:
414
566
  repo_path = _get_repo(name)
415
- success, output = await _run_container_sh(repo_path, 'start')
416
- return ContainerScriptResponse(name=name, action='start', success=success, output=output)
567
+ existing = _container_start_status_by_repo.get(name)
568
+ if existing and existing.get('running'):
569
+ return _build_container_start_status_response(existing)
570
+
571
+ job = _new_container_start_status(name)
572
+ asyncio.create_task(_run_container_start(repo_path, name))
573
+ return _build_container_start_status_response(job)
574
+
575
+
576
+ @router.get(
577
+ '/{name}/container/start/status',
578
+ response_model=ContainerStartStatusResponse,
579
+ )
580
+ async def get_start_repo_container_status(
581
+ name: str,
582
+ ) -> ContainerStartStatusResponse:
583
+ _get_repo(name)
584
+ job = _container_start_status_by_repo.get(name)
585
+ if job is None:
586
+ raise HTTPException(
587
+ status_code=status.HTTP_404_NOT_FOUND,
588
+ detail=f"Container start status for '{name}' not found",
589
+ )
590
+ return _build_container_start_status_response(job)
417
591
 
418
592
 
419
593
  @router.post('/{name}/update', response_model=UpdateResponse)
@@ -0,0 +1,156 @@
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
+ """Host system status and process endpoints."""
20
+
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from cyclo_host_agent.models import (
25
+ HostProcessesResponse,
26
+ HostProcessInfo,
27
+ HostSystemStatsResponse,
28
+ )
29
+ from fastapi import APIRouter, Query
30
+ import psutil
31
+
32
+ router = APIRouter(prefix='/system', tags=['system'])
33
+
34
+ PROCESS_SAMPLE_INTERVAL_SECONDS = 0.2
35
+ PROCESS_DEFAULT_LIMIT = 80
36
+ EXTRA_STORAGE_MOUNT_PATHS = ('/mnt/ssd', '/data')
37
+
38
+
39
+ def _temperature() -> float | None:
40
+ try:
41
+ sensors = psutil.sensors_temperatures()
42
+ if not sensors:
43
+ return None
44
+ for readings in sensors.values():
45
+ if readings:
46
+ return round(float(readings[0].current), 1)
47
+ except (AttributeError, Exception):
48
+ pass
49
+ return None
50
+
51
+
52
+ def _mounted_disk_usage(path: str):
53
+ path_obj = Path(path)
54
+ if not path_obj.is_mount():
55
+ return None
56
+ try:
57
+ return psutil.disk_usage(path)
58
+ except OSError:
59
+ return None
60
+
61
+
62
+ def _extra_storage_disk_usage():
63
+ for mount_path in EXTRA_STORAGE_MOUNT_PATHS:
64
+ usage = _mounted_disk_usage(mount_path)
65
+ if usage:
66
+ return mount_path, usage
67
+ return None, None
68
+
69
+
70
+ def _command_for_process(process: psutil.Process, info: dict) -> str:
71
+ cmdline = info.get('cmdline') or []
72
+ if cmdline:
73
+ return ' '.join(str(part) for part in cmdline)
74
+ return str(info.get('name') or process.pid)
75
+
76
+
77
+ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesResponse:
78
+ tracked: list[psutil.Process] = []
79
+ cpu_count = psutil.cpu_count() or 1
80
+ for process in psutil.process_iter(['pid']):
81
+ try:
82
+ process.cpu_percent(None)
83
+ tracked.append(process)
84
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
85
+ continue
86
+
87
+ overall_cpu_percent = psutil.cpu_percent(interval=PROCESS_SAMPLE_INTERVAL_SECONDS)
88
+ mem = psutil.virtual_memory()
89
+
90
+ processes: list[HostProcessInfo] = []
91
+ for process in tracked:
92
+ try:
93
+ info = process.as_dict(
94
+ attrs=['pid', 'username', 'cpu_percent', 'memory_percent', 'memory_info', 'cmdline', 'name']
95
+ )
96
+ memory_info = info.get('memory_info')
97
+ rss_kb = int(memory_info.rss // 1024) if memory_info else None
98
+ process_cpu_percent = float(info.get('cpu_percent') or 0.0) / cpu_count
99
+ processes.append(
100
+ HostProcessInfo(
101
+ pid=int(info['pid']),
102
+ user=str(info.get('username') or ''),
103
+ cpu_percent=round(process_cpu_percent, 1),
104
+ memory_percent=round(float(info.get('memory_percent') or 0.0), 1),
105
+ rss_kb=rss_kb,
106
+ command=_command_for_process(process, info),
107
+ )
108
+ )
109
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
110
+ continue
111
+
112
+ processes.sort(key=lambda item: item.cpu_percent, reverse=True)
113
+ return HostProcessesResponse(
114
+ cpu_percent=round(float(overall_cpu_percent), 1),
115
+ memory_used_mb=int(mem.used // (1024 * 1024)),
116
+ memory_total_mb=int(mem.total // (1024 * 1024)),
117
+ processes=processes[:max(1, min(limit, 500))],
118
+ )
119
+
120
+
121
+ @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)
125
+ mem = psutil.virtual_memory()
126
+ disk = psutil.disk_usage('/')
127
+ extra_storage_path, extra_storage_disk = _extra_storage_disk_usage()
128
+ uptime_seconds = int(time.time() - psutil.boot_time())
129
+ return HostSystemStatsResponse(
130
+ cpu_percent=round(float(cpu_percent), 1),
131
+ memory_used_mb=int(mem.used // (1024 * 1024)),
132
+ memory_total_mb=int(mem.total // (1024 * 1024)),
133
+ disk_used_gb=round(float(disk.used / (1024 ** 3)), 1),
134
+ disk_total_gb=round(float(disk.total / (1024 ** 3)), 1),
135
+ ssd_used_gb=(
136
+ round(float(extra_storage_disk.used / (1024 ** 3)), 1)
137
+ if extra_storage_disk
138
+ else None
139
+ ),
140
+ ssd_total_gb=(
141
+ round(float(extra_storage_disk.total / (1024 ** 3)), 1)
142
+ if extra_storage_disk
143
+ else None
144
+ ),
145
+ ssd_mount_path=extra_storage_path,
146
+ uptime_seconds=uptime_seconds,
147
+ temperature_celsius=_temperature(),
148
+ )
149
+
150
+
151
+ @router.get('/processes', response_model=HostProcessesResponse)
152
+ async def get_system_processes(
153
+ limit: int = Query(PROCESS_DEFAULT_LIMIT, ge=1, le=500),
154
+ ) -> HostProcessesResponse:
155
+ """Return host process CPU/memory usage sorted by CPU usage."""
156
+ return _sample_processes(limit)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 1.0.0.dev10
3
+ Version: 1.1.0.dev1
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
@@ -18,7 +18,7 @@ Requires-Dist: ruff; extra == "dev"
18
18
  **Console commands:** `cyclo_manager` (underscore), `cyclo-manager` (hyphen)
19
19
  **Host agent:** `cyclo_host_agent` (installed by the package, run via systemd)
20
20
 
21
- This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It:
21
+ This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It is intended for production robots that run the pre-built Docker images. It:
22
22
 
23
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
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).
@@ -50,11 +50,12 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
50
50
 
51
51
  ## Prerequisites
52
52
 
53
- - **Docker** with **Compose v2** (`docker compose` — the legacy `docker-compose` binary alone is not enough)
53
+ - **Docker** with **Compose v2** (`docker compose`; the legacy `docker-compose` binary alone is not enough)
54
54
  - **Python 3.10+**
55
+ - **systemd**: required for the managed `cyclo_host_agent.service`
55
56
  - **`sudo`** — required on normal-user hosts to install the host agent systemd unit, socket directory permissions, and sudoers rules
56
57
  - For **`cyclo_manager update`:** **`pip`** or **`pip3`** on `PATH`
57
- - **Root shell handling** — root is rejected when a normal login user exists. On root-only devices, the host agent is installed as root.
58
+ - **Root shell handling** — `cyclo_manager up` rejects direct root execution when a normal login user exists. On root-only devices, the host agent is installed as root.
58
59
  - **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
 
60
61
  ---
@@ -94,6 +95,8 @@ python3 -m cyclo_manager_cli.cli up
94
95
  | `cyclo_manager update --no-pull` | Same as `update`, but skip image pull (use locally cached images). |
95
96
  | `cyclo_manager --help` | Subcommand overview |
96
97
 
98
+ The CLI also has an internal `refresh-host-agent` subcommand. It is used by the sudoers rule and should normally not be invoked by users directly.
99
+
97
100
  **`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`.
98
101
 
99
102
  ---
@@ -120,18 +123,21 @@ The pip package installs a small **FastAPI** server that listens on a **Unix dom
120
123
  |------|------|
121
124
  | Socket | `/var/run/robotis/agent_sockets/host/host_agent.sock` |
122
125
  | systemd unit | `cyclo_host_agent.service` |
123
- | sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for UI updates; skipped on root-only devices) |
126
+ | sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for package-update refresh; skipped on root-only devices) |
124
127
 
125
128
  The API container reaches it at `/agents/host/host_agent.sock` (see bundled `config.yml`).
126
129
 
127
130
  **Responsibilities:**
128
131
 
129
- - List and update **`~/ROBOTIS-GIT/*`** git repositories (Version Management in the UI)
130
- - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`
132
+ - List managed **ROBOTIS-GIT** git repositories in the configured workspace.
133
+ - Check update availability from remote tags and local `package.xml` versions.
134
+ - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
135
+ - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
136
+ - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
131
137
 
132
138
  `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.
133
139
 
134
- Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically.
140
+ Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
135
141
 
136
142
  ---
137
143
 
@@ -211,6 +217,8 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
211
217
  | Variable | Set by | Purpose |
212
218
  |----------|--------|---------|
213
219
  | **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
220
+ | **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
221
+ | **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
214
222
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
215
223
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
216
224
  | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
@@ -252,7 +260,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
252
260
  |---------|---------|
253
261
  | **fastapi** | `cyclo_host_agent` HTTP API |
254
262
  | **uvicorn** | `cyclo_host_agent` server |
255
- | **psutil** | Host agent / system utilities |
263
+ | **psutil** | Declared package dependency for host/system utility support |
256
264
 
257
265
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
258
266
 
@@ -5,7 +5,9 @@ cyclo_host_agent/__init__.py
5
5
  cyclo_host_agent/main.py
6
6
  cyclo_host_agent/models.py
7
7
  cyclo_host_agent/routers/__init__.py
8
+ cyclo_host_agent/routers/files.py
8
9
  cyclo_host_agent/routers/repos.py
10
+ cyclo_host_agent/routers/system_stats.py
9
11
  cyclo_host_agent/routers/update.py
10
12
  cyclo_manager.egg-info/PKG-INFO
11
13
  cyclo_manager.egg-info/SOURCES.txt
@@ -11,7 +11,7 @@ services:
11
11
 
12
12
  cyclo_manager:
13
13
  container_name: cyclo_manager
14
- image: robotis/cyclo-manager:1.0.0
14
+ image: robotis/cyclo-manager:1.1.0
15
15
  restart: always
16
16
  network_mode: host
17
17
  volumes:
@@ -27,7 +27,7 @@ services:
27
27
 
28
28
  ui:
29
29
  container_name: cyclo_manager_ui
30
- image: robotis/cyclo-manager-ui:1.0.0
30
+ image: robotis/cyclo-manager-ui:1.1.0
31
31
  restart: always
32
32
  network_mode: host
33
33
  environment:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cyclo-manager"
7
- version = "1.0.0.dev10"
7
+ version = "1.1.0.dev1"
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"