cyclo-manager 0.4.0.dev0__tar.gz → 1.0.0__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 (20) hide show
  1. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/PKG-INFO +26 -20
  2. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/README.md +25 -19
  3. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_host_agent/routers/repos.py +19 -5
  4. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_host_agent/routers/update.py +17 -14
  5. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager.egg-info/PKG-INFO +26 -20
  6. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager_cli/__init__.py +1 -1
  7. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager_cli/cli.py +157 -30
  8. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager_cli/config/config.yml +4 -1
  9. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager_cli/docker/docker-compose.yml +2 -2
  10. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/pyproject.toml +1 -1
  11. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_host_agent/__init__.py +0 -0
  12. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_host_agent/main.py +0 -0
  13. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_host_agent/models.py +0 -0
  14. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_host_agent/routers/__init__.py +0 -0
  15. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager.egg-info/SOURCES.txt +0 -0
  16. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  17. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager.egg-info/entry_points.txt +0 -0
  18. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager.egg-info/requires.txt +0 -0
  19. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/cyclo_manager.egg-info/top_level.txt +0 -0
  20. {cyclo_manager-0.4.0.dev0 → cyclo_manager-1.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 0.4.0.dev0
3
+ Version: 1.0.0
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
- - **`sudo`** — required to install the host agent systemd unit, socket directory permissions, and sudoers rules
55
+ - **systemd**: required for the managed `cyclo_host_agent.service`
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
- - **Do not run as root** — the host agent runs as the invoking user (or `SUDO_USER` when using `sudo cyclo_manager up`). Running as root is rejected.
58
+ - **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,16 +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) |
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 update`** when triggered from the UI (`POST /host/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`.
137
+
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.
131
139
 
132
- `cyclo_manager up` and `cyclo_manager update` both call `_ensure_host_agent()`, which is idempotent — safe to run after upgrades or user changes.
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.
133
141
 
134
142
  ---
135
143
 
@@ -139,8 +147,8 @@ Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/doc
139
147
 
140
148
  | Compose service | Container name | `cyclo_manager up` | Image (example) |
141
149
  |-----------------|----------------|--------------------|-----------------|
142
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:0.4.0` |
143
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:0.4.0` |
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` |
144
152
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
145
153
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
146
154
 
@@ -160,7 +168,7 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
160
168
 
161
169
  | Key | Description |
162
170
  |-----|-------------|
163
- | **`robot_container`** | Primary robot Docker container name (e.g. `ai_worker`). Used by the UI System page and service bringup. Must be a key in `sockets` and cannot be `host_agent`. |
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`. |
164
172
  | **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
165
173
 
166
174
  s6 **service names** are not listed in config; each in-container agent reports them at runtime.
@@ -168,20 +176,17 @@ s6 **service names** are not listed in config; each in-container agent reports t
168
176
  ### Example (bundled default)
169
177
 
170
178
  ```yaml
171
- robot_container: ai_worker
179
+ supported_robot_containers:
180
+ - ai_worker
181
+ - open_manipulator
172
182
 
173
183
  sockets:
174
184
  ai_worker: "/agents/ai_worker/s6_agent.sock"
185
+ open_manipulator: "/agents/open_manipulator/s6_agent.sock"
175
186
  cyclo_intelligence: "/agents/cyclo_intelligence/s6_agent.sock"
176
187
  host_agent: "/agents/host/host_agent.sock"
177
188
  ```
178
189
 
179
- ### Validation (API startup)
180
-
181
- - At least one socket entry besides `host_agent`
182
- - `robot_container` must exist in `sockets`
183
- - All socket paths must be non-empty strings
184
-
185
190
  To use a different config layout, mounts, or local source builds, use the repository’s **`docker-compose.dev.yml`** — see [Custom config and development](#custom-config-and-development).
186
191
 
187
192
  ---
@@ -212,6 +217,7 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
212
217
  | Variable | Set by | Purpose |
213
218
  |----------|--------|---------|
214
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 |
215
221
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
216
222
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
217
223
  | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
@@ -253,7 +259,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
253
259
  |---------|---------|
254
260
  | **fastapi** | `cyclo_host_agent` HTTP API |
255
261
  | **uvicorn** | `cyclo_host_agent` server |
256
- | **psutil** | Host agent / system utilities |
262
+ | **psutil** | Declared package dependency for host/system utility support |
257
263
 
258
264
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
259
265
 
@@ -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
- - **`sudo`** — required to install the host agent systemd unit, socket directory permissions, and sudoers rules
41
+ - **systemd**: required for the managed `cyclo_host_agent.service`
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
- - **Do not run as root** — the host agent runs as the invoking user (or `SUDO_USER` when using `sudo cyclo_manager up`). Running as root is rejected.
44
+ - **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,16 +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) |
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 update`** when triggered from the UI (`POST /host/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`.
123
+
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.
117
125
 
118
- `cyclo_manager up` and `cyclo_manager update` both call `_ensure_host_agent()`, which is idempotent — safe to run after upgrades or user changes.
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.
119
127
 
120
128
  ---
121
129
 
@@ -125,8 +133,8 @@ Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/doc
125
133
 
126
134
  | Compose service | Container name | `cyclo_manager up` | Image (example) |
127
135
  |-----------------|----------------|--------------------|-----------------|
128
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:0.4.0` |
129
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:0.4.0` |
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` |
130
138
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
131
139
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
132
140
 
@@ -146,7 +154,7 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
146
154
 
147
155
  | Key | Description |
148
156
  |-----|-------------|
149
- | **`robot_container`** | Primary robot Docker container name (e.g. `ai_worker`). Used by the UI System page and service bringup. Must be a key in `sockets` and cannot be `host_agent`. |
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`. |
150
158
  | **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
151
159
 
152
160
  s6 **service names** are not listed in config; each in-container agent reports them at runtime.
@@ -154,20 +162,17 @@ s6 **service names** are not listed in config; each in-container agent reports t
154
162
  ### Example (bundled default)
155
163
 
156
164
  ```yaml
157
- robot_container: ai_worker
165
+ supported_robot_containers:
166
+ - ai_worker
167
+ - open_manipulator
158
168
 
159
169
  sockets:
160
170
  ai_worker: "/agents/ai_worker/s6_agent.sock"
171
+ open_manipulator: "/agents/open_manipulator/s6_agent.sock"
161
172
  cyclo_intelligence: "/agents/cyclo_intelligence/s6_agent.sock"
162
173
  host_agent: "/agents/host/host_agent.sock"
163
174
  ```
164
175
 
165
- ### Validation (API startup)
166
-
167
- - At least one socket entry besides `host_agent`
168
- - `robot_container` must exist in `sockets`
169
- - All socket paths must be non-empty strings
170
-
171
176
  To use a different config layout, mounts, or local source builds, use the repository’s **`docker-compose.dev.yml`** — see [Custom config and development](#custom-config-and-development).
172
177
 
173
178
  ---
@@ -198,6 +203,7 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
198
203
  | Variable | Set by | Purpose |
199
204
  |----------|--------|---------|
200
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 |
201
207
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
202
208
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
203
209
  | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
@@ -239,7 +245,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
239
245
  |---------|---------|
240
246
  | **fastapi** | `cyclo_host_agent` HTTP API |
241
247
  | **uvicorn** | `cyclo_host_agent` server |
242
- | **psutil** | Host agent / system utilities |
248
+ | **psutil** | Declared package dependency for host/system utility support |
243
249
 
244
250
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
245
251
 
@@ -41,16 +41,20 @@ from fastapi import APIRouter, HTTPException, status
41
41
  router = APIRouter(prefix='/repos', tags=['repos'])
42
42
 
43
43
  GIT_TIMEOUT = 120.0
44
+ GIT_REMOTE_TIMEOUT = 5.0
44
45
 
45
46
 
46
- def _resolve_home() -> Path:
47
+ def _resolve_workspace() -> Path:
48
+ configured_workspace = os.environ.get('CYCLO_HOST_AGENT_WORKSPACE')
49
+ if configured_workspace:
50
+ return Path(configured_workspace).expanduser()
47
51
  sudo_user = os.environ.get('SUDO_USER')
48
52
  if sudo_user:
49
53
  return Path(f'/home/{sudo_user}')
50
54
  return Path.home()
51
55
 
52
56
 
53
- WORKSPACE_PATH = _resolve_home()
57
+ WORKSPACE_PATH = _resolve_workspace()
54
58
  MANAGED_GITHUB_ORG = 'ROBOTIS-GIT'
55
59
  ALLOWED_UPDATE_BRANCHES = frozenset({'main', 'jazzy'})
56
60
 
@@ -120,7 +124,11 @@ async def _ensure_allowed_branch(repo_path: Path) -> None:
120
124
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
121
125
 
122
126
 
123
- async def _git(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
127
+ async def _git(
128
+ args: list[str],
129
+ cwd: Optional[Path] = None,
130
+ timeout: float = GIT_TIMEOUT,
131
+ ) -> tuple[int, str, str]:
124
132
  proc = await asyncio.create_subprocess_exec(
125
133
  'git', *args,
126
134
  cwd=str(cwd) if cwd else None,
@@ -128,7 +136,7 @@ async def _git(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, s
128
136
  stderr=asyncio.subprocess.PIPE,
129
137
  )
130
138
  try:
131
- stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=GIT_TIMEOUT)
139
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
132
140
  except asyncio.TimeoutError:
133
141
  proc.kill()
134
142
  await proc.wait()
@@ -192,7 +200,13 @@ def _read_package_xml_version(repo_path: Path) -> Optional[str]:
192
200
 
193
201
 
194
202
  async def _fetch_latest_tag(remote_url: str) -> Optional[str]:
195
- rc, out, _ = await _git(['ls-remote', '--tags', remote_url])
203
+ try:
204
+ rc, out, _ = await _git(
205
+ ['ls-remote', '--tags', remote_url],
206
+ timeout=GIT_REMOTE_TIMEOUT,
207
+ )
208
+ except asyncio.TimeoutError:
209
+ return None
196
210
  if rc != 0:
197
211
  return None
198
212
  tags = []
@@ -19,28 +19,33 @@
19
19
  """System-level endpoints for the host agent."""
20
20
 
21
21
  import asyncio
22
- import logging
23
- import shutil
22
+ import sys
24
23
 
25
24
  from fastapi import APIRouter, HTTPException, status
26
25
 
27
- logger = logging.getLogger(__name__)
28
-
29
26
  router = APIRouter(prefix='/system', tags=['system'])
30
27
 
31
28
  _update_status: dict = {'phase': 'idle', 'output': '', 'error': ''}
32
29
 
33
30
 
34
- async def _run_update(cyclo_exe: str) -> None:
31
+ @router.get('/version')
32
+ async def get_host_agent_version() -> dict:
33
+ """Return the running cyclo_host_agent package version."""
34
+ from cyclo_manager_cli import __version__
35
+
36
+ return {'version': __version__}
37
+
38
+
39
+ async def _run_update() -> None:
35
40
  global _update_status
36
41
  try:
37
42
  proc = await asyncio.create_subprocess_exec(
38
- cyclo_exe, 'update',
43
+ sys.executable, '-m', 'cyclo_manager_cli.cli', 'update',
39
44
  stdout=asyncio.subprocess.PIPE,
40
45
  stderr=asyncio.subprocess.PIPE,
41
46
  )
42
47
  stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
43
- out = (stdout.decode() + stderr.decode()).strip()
48
+ out = (stdout.decode(errors='replace') + stderr.decode(errors='replace')).strip()
44
49
  _update_status['output'] = out
45
50
  if proc.returncode != 0:
46
51
  _update_status['phase'] = 'error'
@@ -50,24 +55,22 @@ async def _run_update(cyclo_exe: str) -> None:
50
55
  except asyncio.TimeoutError:
51
56
  _update_status['phase'] = 'error'
52
57
  _update_status['error'] = 'cyclo_manager update timed out'
58
+ except Exception as e:
59
+ _update_status['phase'] = 'error'
60
+ _update_status['error'] = f'Failed to start cyclo_manager update: {e}'
53
61
 
54
62
 
55
63
  @router.post('/update')
56
64
  async def start_update() -> dict:
57
- """Delegate update to cyclo_manager update, which handles containers and host agent restart."""
65
+ """Run cyclo_manager update in the background."""
58
66
  global _update_status
59
67
 
60
- cyclo_exe = shutil.which('cyclo_manager')
61
- if not cyclo_exe:
62
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
63
- detail='cyclo_manager command not found')
64
-
65
68
  if _update_status.get('phase') == 'updating':
66
69
  raise HTTPException(status_code=status.HTTP_409_CONFLICT,
67
70
  detail='Update already in progress')
68
71
 
69
72
  _update_status = {'phase': 'updating', 'output': '', 'error': ''}
70
- asyncio.create_task(_run_update(cyclo_exe))
73
+ asyncio.create_task(_run_update())
71
74
  return {'status': 'update started'}
72
75
 
73
76
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 0.4.0.dev0
3
+ Version: 1.0.0
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
- - **`sudo`** — required to install the host agent systemd unit, socket directory permissions, and sudoers rules
55
+ - **systemd**: required for the managed `cyclo_host_agent.service`
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
- - **Do not run as root** — the host agent runs as the invoking user (or `SUDO_USER` when using `sudo cyclo_manager up`). Running as root is rejected.
58
+ - **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,16 +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) |
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 update`** when triggered from the UI (`POST /host/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`.
137
+
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.
131
139
 
132
- `cyclo_manager up` and `cyclo_manager update` both call `_ensure_host_agent()`, which is idempotent — safe to run after upgrades or user changes.
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.
133
141
 
134
142
  ---
135
143
 
@@ -139,8 +147,8 @@ Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/doc
139
147
 
140
148
  | Compose service | Container name | `cyclo_manager up` | Image (example) |
141
149
  |-----------------|----------------|--------------------|-----------------|
142
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:0.4.0` |
143
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:0.4.0` |
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` |
144
152
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
145
153
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
146
154
 
@@ -160,7 +168,7 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
160
168
 
161
169
  | Key | Description |
162
170
  |-----|-------------|
163
- | **`robot_container`** | Primary robot Docker container name (e.g. `ai_worker`). Used by the UI System page and service bringup. Must be a key in `sockets` and cannot be `host_agent`. |
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`. |
164
172
  | **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
165
173
 
166
174
  s6 **service names** are not listed in config; each in-container agent reports them at runtime.
@@ -168,20 +176,17 @@ s6 **service names** are not listed in config; each in-container agent reports t
168
176
  ### Example (bundled default)
169
177
 
170
178
  ```yaml
171
- robot_container: ai_worker
179
+ supported_robot_containers:
180
+ - ai_worker
181
+ - open_manipulator
172
182
 
173
183
  sockets:
174
184
  ai_worker: "/agents/ai_worker/s6_agent.sock"
185
+ open_manipulator: "/agents/open_manipulator/s6_agent.sock"
175
186
  cyclo_intelligence: "/agents/cyclo_intelligence/s6_agent.sock"
176
187
  host_agent: "/agents/host/host_agent.sock"
177
188
  ```
178
189
 
179
- ### Validation (API startup)
180
-
181
- - At least one socket entry besides `host_agent`
182
- - `robot_container` must exist in `sockets`
183
- - All socket paths must be non-empty strings
184
-
185
190
  To use a different config layout, mounts, or local source builds, use the repository’s **`docker-compose.dev.yml`** — see [Custom config and development](#custom-config-and-development).
186
191
 
187
192
  ---
@@ -212,6 +217,7 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
212
217
  | Variable | Set by | Purpose |
213
218
  |----------|--------|---------|
214
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 |
215
221
  | **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
216
222
  | **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
217
223
  | **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
@@ -253,7 +259,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
253
259
  |---------|---------|
254
260
  | **fastapi** | `cyclo_host_agent` HTTP API |
255
261
  | **uvicorn** | `cyclo_host_agent` server |
256
- | **psutil** | Host agent / system utilities |
262
+ | **psutil** | Declared package dependency for host/system utility support |
257
263
 
258
264
  The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
259
265
 
@@ -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__ = '0.4.0'
21
+ __version__ = '1.0.0'
@@ -26,6 +26,7 @@ Launches cyclo_manager server and UI via Docker.
26
26
  import argparse
27
27
  import os
28
28
  from pathlib import Path
29
+ import pwd
29
30
  import shutil
30
31
  import socket
31
32
  import subprocess
@@ -36,10 +37,11 @@ HOST_AGENT_SERVICE = 'cyclo_host_agent'
36
37
  HOST_AGENT_SOCKET = '/var/run/robotis/agent_sockets/host/host_agent.sock'
37
38
  HOST_AGENT_SOCKET_DIR = '/var/run/robotis/agent_sockets/host'
38
39
 
39
- # `cyclo_manager up` starts these immediately.
40
+ # `cyclo_manager up` starts these Containers immediately.
40
41
  COMPOSE_SERVICES_UP = ('cyclo_manager', 'ui')
41
42
  # These get `docker compose create` only (stopped); start from UI or `docker start`.
42
43
  COMPOSE_SERVICES_CREATE_ONLY = ('rmw_zenoh', 'novnc-server')
44
+ DISABLED_LOGIN_SHELLS = {'', '/bin/false', '/sbin/nologin', '/usr/sbin/nologin'}
43
45
 
44
46
 
45
47
  def _docker_dir() -> Path:
@@ -55,6 +57,47 @@ def _packaged_config_path() -> Path:
55
57
  return _config_dir() / 'config.yml'
56
58
 
57
59
 
60
+ def _sudo_cmd(args: list[str]) -> list[str]:
61
+ """Return args prefixed with sudo when the current process is not already root."""
62
+ if os.geteuid() == 0:
63
+ return args
64
+ return ['sudo', *args]
65
+
66
+
67
+ def _user_home(user: str) -> Path:
68
+ """Return a user's home directory from passwd, with a conservative fallback."""
69
+ try:
70
+ return Path(pwd.getpwnam(user).pw_dir)
71
+ except KeyError:
72
+ if user == 'root':
73
+ return Path('/root')
74
+ return Path(f'/home/{user}')
75
+
76
+
77
+ def _has_normal_login_user() -> bool:
78
+ """Return True when the host has a non-root login user."""
79
+ for account in pwd.getpwall():
80
+ if account.pw_name == 'root':
81
+ continue
82
+ if account.pw_uid < 1000 or account.pw_uid >= 65534:
83
+ continue
84
+ if account.pw_shell in DISABLED_LOGIN_SHELLS:
85
+ continue
86
+ return True
87
+ return False
88
+
89
+
90
+ def _host_agent_workspace(user: str, user_home: Path) -> Path:
91
+ """Return the workspace path the host agent should scan for git repositories."""
92
+ configured_workspace = os.environ.get('CYCLO_HOST_AGENT_WORKSPACE')
93
+ if configured_workspace:
94
+ return Path(configured_workspace).expanduser()
95
+ data_docker = Path('/data/docker')
96
+ if user == 'root' and data_docker.is_dir():
97
+ return data_docker
98
+ return user_home
99
+
100
+
58
101
  def _resolve_service_user() -> tuple[str, Path] | None:
59
102
  """
60
103
  Return (username, home) for the host agent systemd service.
@@ -64,9 +107,15 @@ def _resolve_service_user() -> tuple[str, Path] | None:
64
107
  """
65
108
  sudo_user = os.environ.get('SUDO_USER')
66
109
  if sudo_user:
67
- return sudo_user, Path(f'/home/{sudo_user}')
68
- user = os.environ.get('USER') or os.getlogin()
110
+ return sudo_user, _user_home(sudo_user)
111
+ user = os.environ.get('USER') or pwd.getpwuid(os.getuid()).pw_name
69
112
  if user == 'root':
113
+ if not _has_normal_login_user():
114
+ print(
115
+ 'Info: root-only device detected; cyclo_host_agent will run as root.',
116
+ file=sys.stderr,
117
+ )
118
+ return 'root', Path('/root')
70
119
  print(
71
120
  'Error: cyclo_manager up must run as a normal user (e.g. robotis), not root.',
72
121
  file=sys.stderr,
@@ -85,19 +134,19 @@ def _setup_socket_dir(user: str) -> bool:
85
134
  print(f' [1/3] Creating socket directory {HOST_AGENT_SOCKET_DIR}...')
86
135
  try:
87
136
  subprocess.run(
88
- ['sudo', 'mkdir', '-p', HOST_AGENT_SOCKET_DIR],
137
+ _sudo_cmd(['mkdir', '-p', HOST_AGENT_SOCKET_DIR]),
89
138
  check=True,
90
139
  capture_output=True,
91
140
  )
92
141
  print(f' [2/3] Setting ownership to {user}:{user}...')
93
142
  subprocess.run(
94
- ['sudo', 'chown', f'{user}:{user}', HOST_AGENT_SOCKET_DIR],
143
+ _sudo_cmd(['chown', f'{user}:{user}', HOST_AGENT_SOCKET_DIR]),
95
144
  check=True,
96
145
  capture_output=True,
97
146
  )
98
147
  print(' [3/3] Removing stale socket file (if any)...')
99
148
  subprocess.run(
100
- ['sudo', 'rm', '-f', HOST_AGENT_SOCKET],
149
+ _sudo_cmd(['rm', '-f', HOST_AGENT_SOCKET]),
101
150
  check=True,
102
151
  capture_output=True,
103
152
  )
@@ -111,25 +160,34 @@ def _install_sudoers(user: str) -> bool:
111
160
  """
112
161
  Write sudoers rules for cyclo_manager.
113
162
 
114
- Grant NOPASSWD for udev operations needed by container.sh
115
- (cp to /etc/udev/rules.d/, udevadm control/trigger).
163
+ Grant NOPASSWD for udev operations and the root-only host-agent refresh command.
116
164
  """
117
- print(' Installing sudoers rules (cp, udevadm)...')
165
+ if not sys.executable:
166
+ print(
167
+ 'Python executable is unknown. '
168
+ 'Run `pip install cyclo-manager` and retry.',
169
+ file=sys.stderr,
170
+ )
171
+ return False
172
+
173
+ print(' Installing sudoers rules (cp, udevadm, host-agent refresh command)...')
118
174
  sudoers_content = (
119
175
  f'{user} ALL=(ALL) NOPASSWD: /usr/bin/cp, '
120
176
  f'/usr/bin/udevadm control --reload-rules, '
121
- f'/usr/bin/udevadm trigger\n'
177
+ f'/usr/bin/udevadm trigger, '
178
+ 'SETENV: '
179
+ f'{sys.executable} -m cyclo_manager_cli.cli refresh-host-agent\n'
122
180
  )
123
181
  sudoers_file = '/etc/sudoers.d/cyclo_manager'
124
182
  try:
125
183
  subprocess.run(
126
- ['sudo', 'tee', sudoers_file],
184
+ _sudo_cmd(['tee', sudoers_file]),
127
185
  input=sudoers_content.encode(),
128
186
  capture_output=True,
129
187
  check=True,
130
188
  )
131
189
  subprocess.run(
132
- ['sudo', 'chmod', '440', sudoers_file],
190
+ _sudo_cmd(['chmod', '440', sudoers_file]),
133
191
  check=True,
134
192
  capture_output=True,
135
193
  )
@@ -139,7 +197,7 @@ def _install_sudoers(user: str) -> bool:
139
197
  return False
140
198
 
141
199
 
142
- def _ensure_host_agent() -> int:
200
+ def _ensure_host_agent(restart_no_block: bool = False) -> int:
143
201
  """
144
202
  Install or refresh cyclo_host_agent: systemd unit, socket dir, and service start.
145
203
 
@@ -151,10 +209,11 @@ def _ensure_host_agent() -> int:
151
209
  return 1
152
210
 
153
211
  user, user_home = account
154
- agent_exe = shutil.which('cyclo_host_agent')
155
- if not agent_exe:
212
+ workspace_path = _host_agent_workspace(user, user_home)
213
+ agent_exec_start = f'{sys.executable} -m cyclo_host_agent.main' if sys.executable else None
214
+ if not agent_exec_start:
156
215
  print(
157
- 'Warning: cyclo_host_agent not found in PATH. '
216
+ 'Warning: Python executable is unknown. '
158
217
  'Run `pip install cyclo-manager` and retry.',
159
218
  file=sys.stderr,
160
219
  )
@@ -163,7 +222,7 @@ def _ensure_host_agent() -> int:
163
222
  if not _setup_socket_dir(user):
164
223
  return 1
165
224
 
166
- if not _install_sudoers(user):
225
+ if user != 'root' and not _install_sudoers(user):
167
226
  return 1
168
227
 
169
228
  service_content = f"""\
@@ -177,8 +236,9 @@ User={user}
177
236
  Group={user}
178
237
  RuntimeDirectory=robotis/agent_sockets/host
179
238
  RuntimeDirectoryMode=0755
180
- ExecStart={agent_exe}
239
+ ExecStart={agent_exec_start}
181
240
  Environment=HOME={user_home}
241
+ Environment=CYCLO_HOST_AGENT_WORKSPACE={workspace_path}
182
242
  Restart=always
183
243
  RestartSec=3
184
244
 
@@ -192,22 +252,29 @@ WantedBy=multi-user.target
192
252
  try:
193
253
  print(f' Writing unit file to {service_file}...')
194
254
  subprocess.run(
195
- ['sudo', 'tee', service_file],
255
+ _sudo_cmd(['tee', service_file]),
196
256
  input=service_content.encode(),
197
257
  capture_output=True,
198
258
  check=True,
199
259
  )
200
260
  print(' Running systemctl daemon-reload...')
201
- subprocess.run(['sudo', 'systemctl', 'daemon-reload'], check=True, capture_output=True)
261
+ subprocess.run(
262
+ _sudo_cmd(['systemctl', 'daemon-reload']),
263
+ check=True,
264
+ capture_output=True,
265
+ )
202
266
  print(f' Enabling {HOST_AGENT_SERVICE}...')
203
267
  subprocess.run(
204
- ['sudo', 'systemctl', 'enable', f'{HOST_AGENT_SERVICE}.service'],
268
+ _sudo_cmd(['systemctl', 'enable', f'{HOST_AGENT_SERVICE}.service']),
205
269
  check=True,
206
270
  capture_output=True,
207
271
  )
208
272
  print(f' Starting {HOST_AGENT_SERVICE}...')
273
+ restart_cmd = ['systemctl', 'restart', f'{HOST_AGENT_SERVICE}.service']
274
+ if restart_no_block:
275
+ restart_cmd.insert(1, '--no-block')
209
276
  subprocess.run(
210
- ['sudo', 'systemctl', 'restart', f'{HOST_AGENT_SERVICE}.service'],
277
+ _sudo_cmd(restart_cmd),
211
278
  check=True,
212
279
  capture_output=True,
213
280
  )
@@ -217,10 +284,54 @@ WantedBy=multi-user.target
217
284
  print(f'Failed to install host agent service: {e}', file=sys.stderr)
218
285
  return 1
219
286
  except FileNotFoundError:
220
- print('sudo not found. Install the host agent service manually.', file=sys.stderr)
287
+ print(
288
+ 'Required command not found. Install the host agent service manually.',
289
+ file=sys.stderr,
290
+ )
221
291
  return 1
222
292
 
223
293
 
294
+ def _refresh_host_agent_after_update() -> int:
295
+ """Refresh host agent with the sudoers-registered cyclo_manager command when possible."""
296
+ if os.geteuid() == 0:
297
+ return _ensure_host_agent(restart_no_block=True)
298
+ if not sys.executable:
299
+ print(
300
+ 'Python executable is unknown; falling back to setup.',
301
+ file=sys.stderr,
302
+ )
303
+ return _ensure_host_agent() if sys.stdin.isatty() else 1
304
+
305
+ refresh_command = [sys.executable, '-m', 'cyclo_manager_cli.cli', 'refresh-host-agent']
306
+ print(f'Refreshing host agent with sudo command: {" ".join(refresh_command)}')
307
+ pythonpath = os.pathsep.join(dict.fromkeys(path for path in sys.path if path))
308
+ try:
309
+ subprocess.run(['sudo', '-n', f'PYTHONPATH={pythonpath}', *refresh_command], check=True)
310
+ return 0
311
+ except subprocess.CalledProcessError as e:
312
+ if sys.stdin.isatty():
313
+ print(
314
+ 'Warning: sudo refresh command failed; falling back to interactive setup.',
315
+ file=sys.stderr,
316
+ )
317
+ return _ensure_host_agent()
318
+ print(f'Host-agent refresh command failed: {e}', file=sys.stderr)
319
+ return e.returncode
320
+ except FileNotFoundError:
321
+ if sys.stdin.isatty():
322
+ return _ensure_host_agent()
323
+ print('sudo not found; cannot refresh host agent service.', file=sys.stderr)
324
+ return 1
325
+
326
+
327
+ def cmd_refresh_host_agent(args: argparse.Namespace) -> int:
328
+ """Refresh the host agent from sudoers after a package update."""
329
+ if os.geteuid() != 0:
330
+ print('cyclo_manager refresh-host-agent must run as root.', file=sys.stderr)
331
+ return 1
332
+ return _ensure_host_agent(restart_no_block=True)
333
+
334
+
224
335
  def cmd_up(args: argparse.Namespace) -> int:
225
336
  """Start API + UI; create zenoh and noVNC containers without starting them."""
226
337
  print('--- cyclo_manager up ---')
@@ -272,15 +383,21 @@ def _teardown_host_agent() -> None:
272
383
  """Stop and remove the host agent service, unit file, sudoers, and socket dir."""
273
384
  print(f'Tearing down {HOST_AGENT_SERVICE}...')
274
385
  steps = [
275
- (['sudo', 'systemctl', 'stop', f'{HOST_AGENT_SERVICE}.service'], 'stop service'),
276
- (['sudo', 'systemctl', 'disable', f'{HOST_AGENT_SERVICE}.service'], 'disable service'),
277
386
  (
278
- ['sudo', 'rm', '-f', f'/etc/systemd/system/{HOST_AGENT_SERVICE}.service'],
387
+ _sudo_cmd(['systemctl', 'stop', f'{HOST_AGENT_SERVICE}.service']),
388
+ 'stop service',
389
+ ),
390
+ (
391
+ _sudo_cmd(['systemctl', 'disable', f'{HOST_AGENT_SERVICE}.service']),
392
+ 'disable service',
393
+ ),
394
+ (
395
+ _sudo_cmd(['rm', '-f', f'/etc/systemd/system/{HOST_AGENT_SERVICE}.service']),
279
396
  'remove unit file',
280
397
  ),
281
- (['sudo', 'systemctl', 'daemon-reload'], 'daemon-reload'),
282
- (['sudo', 'rm', '-f', '/etc/sudoers.d/cyclo_manager'], 'remove sudoers'),
283
- (['sudo', 'rm', '-rf', HOST_AGENT_SOCKET_DIR], 'remove socket dir'),
398
+ (_sudo_cmd(['systemctl', 'daemon-reload']), 'daemon-reload'),
399
+ (_sudo_cmd(['rm', '-f', '/etc/sudoers.d/cyclo_manager']), 'remove sudoers'),
400
+ (_sudo_cmd(['rm', '-rf', HOST_AGENT_SOCKET_DIR]), 'remove socket dir'),
284
401
  ]
285
402
  for cmd, desc in steps:
286
403
  print(f' {desc}...')
@@ -384,7 +501,7 @@ def cmd_update(args: argparse.Namespace) -> int:
384
501
  return e.returncode
385
502
 
386
503
  print('Refreshing host agent (unit, socket, sudoers)...')
387
- if _ensure_host_agent() != 0:
504
+ if _refresh_host_agent_after_update() != 0:
388
505
  print('Warning: Failed to refresh host agent service.', file=sys.stderr)
389
506
  return 1
390
507
 
@@ -425,8 +542,18 @@ def main() -> int:
425
542
  )
426
543
  update_parser.set_defaults(func=cmd_update)
427
544
 
545
+ refresh_parser = sub.add_parser(
546
+ 'refresh-host-agent',
547
+ help='Refresh cyclo_host_agent service after package update',
548
+ )
549
+ refresh_parser.set_defaults(func=cmd_refresh_host_agent)
550
+
428
551
  args = parser.parse_args()
429
552
  if not args.command:
430
553
  parser.print_help()
431
554
  return 0
432
555
  return args.func(args)
556
+
557
+
558
+ if __name__ == '__main__':
559
+ raise SystemExit(main())
@@ -1,8 +1,11 @@
1
1
  # cyclo_manager Configuration
2
2
 
3
- robot_container: ai_worker
3
+ supported_robot_containers:
4
+ - ai_worker
5
+ - open_manipulator
4
6
 
5
7
  sockets:
6
8
  ai_worker: "/agents/ai_worker/s6_agent.sock"
9
+ open_manipulator: "/agents/open_manipulator/s6_agent.sock"
7
10
  cyclo_intelligence: "/agents/cyclo_intelligence/s6_agent.sock"
8
11
  host_agent: "/agents/host/host_agent.sock"
@@ -11,7 +11,7 @@ services:
11
11
 
12
12
  cyclo_manager:
13
13
  container_name: cyclo_manager
14
- image: robotis/cyclo-manager:0.4.0
14
+ image: robotis/cyclo-manager:1.0.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:0.4.0
30
+ image: robotis/cyclo-manager-ui:1.0.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 = "0.4.0.dev0"
7
+ version = "1.0.0"
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"