diff --git a/tests/routes/test_container_shell.py b/tests/routes/test_container_shell.py new file mode 100644 index 00000000..ba6c6b59 --- /dev/null +++ b/tests/routes/test_container_shell.py @@ -0,0 +1,305 @@ +"""Tests for the container shell route (issue #462).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + + +# ── GET /api/container-shell/{agent_id} (HTML page) ───────────────────────── + + +class TestContainerShellPage: + """Tests for the container shell HTML page endpoint.""" + + def test_page_returns_html(self, test_client, admin_auth_headers): + """GET /api/container-shell/{agent_id} returns HTML with correct content-type.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_page_contains_container_name(self, test_client, admin_auth_headers): + """The page must show the container name derived from the agent id.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert "taos-agent-test-agent-1" in resp.text + + def test_page_escapes_agent_id(self, test_client, admin_auth_headers): + """HTML-unsafe characters in agent_id must be escaped. + + Uses '&' (valid in URL paths but must be escaped in HTML). + Angle brackets are rejected by Starlette's path router. + """ + resp = test_client.get( + "/api/container-shell/test-&-agent", + headers=admin_auth_headers, + ) + assert resp.status_code == 200 + body = resp.text + assert "&" in body + + def test_page_has_pico_css_reference(self, test_client, admin_auth_headers): + """The page must reference Pico CSS for styling.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert "pico.min.css" in resp.text + + def test_page_has_shell_input(self, test_client, admin_auth_headers): + """The page must include a command input field.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert 'id="shell-cmd"' in resp.text + assert 'type="text"' in resp.text + + def test_page_has_output_region(self, test_client, admin_auth_headers): + """The page must include an output / log region.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert 'id="output"' in resp.text + + def test_page_has_aria_labels(self, test_client, admin_auth_headers): + """Interactive elements must have ARIA labels.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + body = resp.text + assert 'aria-label="Shell command"' in body + assert 'aria-label="Terminal output"' in body + assert 'aria-label="Run command"' in body + + def test_page_has_aria_live_region(self, test_client, admin_auth_headers): + """The output area must be an ARIA live region for screen readers.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert 'aria-live="polite"' in resp.text + assert 'role="log"' in resp.text + + def test_page_references_htmx(self, test_client, admin_auth_headers): + """The page must load htmx for AJAX command submission.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert "htmx" in resp.text + + def test_page_has_run_button(self, test_client, admin_auth_headers): + """The page must include a submit button.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert 'id="shell-btn"' in resp.text + + def test_page_hx_post_targets_exec_endpoint(self, test_client, admin_auth_headers): + """The form must POST to the correct exec endpoint.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert "/api/container-shell/test-agent-1/exec" in resp.text + + def test_page_mentions_container_shell_ready(self, test_client, admin_auth_headers): + """The page should indicate that the container shell is ready.""" + resp = test_client.get("/api/container-shell/test-agent-1", headers=admin_auth_headers) + assert resp.status_code == 200 + assert "Container shell" in resp.text + + +# ── POST /api/container-shell/{agent_id}/exec (command execution) ─────────── + + +class TestContainerShellExec: + """Tests for the command execution endpoint.""" + + def test_exec_rejects_empty_command(self, test_client, admin_auth_headers): + """Empty command must be rejected with an info message.""" + resp = test_client.post( + "/api/container-shell/test-agent/exec", + data={"command": ""}, + headers=admin_auth_headers, + ) + assert resp.status_code == 200 + assert "empty command" in resp.text + + def test_exec_rejects_too_long_command(self, test_client, admin_auth_headers): + """Commands exceeding the max length must be rejected.""" + long_cmd = "x" * 5000 + resp = test_client.post( + "/api/container-shell/test-agent/exec", + data={"command": long_cmd}, + headers=admin_auth_headers, + ) + assert resp.status_code == 200 + assert "too long" in resp.text.lower() + + def test_exec_runs_command_and_returns_output(self, test_client, admin_auth_headers): + """A valid command must execute via incus exec and return HTML output.""" + import asyncio as _asyncio + mock_proc = AsyncMock() + mock_proc.communicate = AsyncMock(return_value=(b"hello world\n", b"")) + mock_proc.returncode = 0 + + with patch( + "tinyagentos.routes.container_shell.asyncio.create_subprocess_exec", + return_value=mock_proc, + ) as mock_exec: + resp = test_client.post( + "/api/container-shell/test-agent/exec", + data={"command": "echo hello"}, + headers=admin_auth_headers, + ) + assert resp.status_code == 200 + + # Verify incus exec was called with the correct container name pattern + mock_exec.assert_called_once() + call_args = mock_exec.call_args[0] + assert call_args[0] == "incus" + assert call_args[1] == "exec" + assert call_args[2] == "taos-agent-test-agent" + assert call_args[3] == "--" + assert call_args[4] == "bash" + assert call_args[5] == "-lc" + assert call_args[6] == "echo hello" + + def test_exec_returns_escaped_html_output(self, test_client, admin_auth_headers): + """Output containing HTML must be escaped.""" + mock_proc = AsyncMock() + mock_proc.communicate = AsyncMock(return_value=(b"\n", b"")) + mock_proc.returncode = 0 + + with patch( + "tinyagentos.routes.container_shell.asyncio.create_subprocess_exec", + return_value=mock_proc, + ): + resp = test_client.post( + "/api/container-shell/test-agent/exec", + data={"command": "echo ' + +""" + + +@router.get("/api/activity", response_class=HTMLResponse) +async def activity_page(request: Request): + """Serve the Activity Feed UI page.""" + # Ensure buffer is initialised + get_buffer(request) + return HTMLResponse(_ACTIVITY_FEED_HTML) diff --git a/tinyagentos/routes/agent_debugger.py b/tinyagentos/routes/agent_debugger.py new file mode 100644 index 00000000..124b9631 --- /dev/null +++ b/tinyagentos/routes/agent_debugger.py @@ -0,0 +1,10 @@ +"""Agent debugger routes — step through tool calls and results. + +Placeholder module. The full implementation is tracked in issue #131. +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter() diff --git a/tinyagentos/routes/agents.py b/tinyagentos/routes/agents.py index 013e527a..b5f02132 100644 --- a/tinyagentos/routes/agents.py +++ b/tinyagentos/routes/agents.py @@ -46,6 +46,62 @@ class AgentUpdate(BaseModel): can_read_user_memory: bool | None = None +class IdempotencyCache: + """In-flight request deduplication cache keyed by Idempotency-Key. + + ``try_reserve(key)`` returns ``("proceed", event)`` when this caller + should do the work, or ``("wait", event)`` when another caller is + already handling this key — the caller should ``await event.wait()`` + and then call ``get(key)`` for the cached result. + + ``set(key, result)`` stores the result and fires the event so all + waiters can proceed. + + This closes the race in the naive get-then-set pattern where + ``cache.get()`` releases the lock before the write, letting a + concurrent retry with the same Idempotency-Key create a duplicate. + """ + + def __init__(self) -> None: + self._entries: dict[str, tuple[asyncio.Event, dict | None]] = {} + + def try_reserve(self, key: str) -> tuple[str, asyncio.Event]: + """Attempt to reserve *key*. + + Returns ``("proceed", event)`` when this caller owns the key + and should perform the work. + + Returns ``("wait", event)`` when another caller already reserved + the key — await ``event.wait()`` and call ``get()`` for the + result. + """ + if key in self._entries: + event, _ = self._entries[key] + return ("wait", event) + + event = asyncio.Event() + self._entries[key] = (event, None) + return ("proceed", event) + + def set(self, key: str, result: dict) -> None: + """Store *result* and wake all waiters on *key*.""" + entry = self._entries.get(key) + if entry is None: + self._entries[key] = (asyncio.Event(), result) + return + event, _ = entry + self._entries[key] = (event, result) + event.set() + + def get(self, key: str) -> dict | None: + """Return the cached result for *key*, or ``None``.""" + entry = self._entries.get(key) + if entry is None: + return None + _, result = entry + return result + + @router.get("/api/agents") async def list_agents(request: Request): """List all configured agents.""" @@ -105,6 +161,15 @@ async def add_agent(request: Request, body: AgentCreate): If the slug collides with an existing agent, a numeric suffix is appended until it's unique. """ + # --- Idempotency guard --- + idempotency_cache = getattr(request.app.state, "idempotency_cache", None) + idempotency_key = request.headers.get("Idempotency-Key") + if idempotency_key and idempotency_cache is not None: + mode, event = idempotency_cache.try_reserve(idempotency_key) + if mode == "wait": + await event.wait() + return idempotency_cache.get(idempotency_key) + config = request.app.state.config display_name = body.name.strip() name_error = validate_agent_name(display_name) @@ -125,7 +190,12 @@ async def add_agent(request: Request, body: AgentCreate): agent["display_name"] = display_name config.agents.append(agent) await save_config_locked(config, config.config_path) - return {"status": "created", "name": unique_slug, "display_name": display_name} + result = {"status": "created", "name": unique_slug, "display_name": display_name} + + if idempotency_key and idempotency_cache is not None: + idempotency_cache.set(idempotency_key, result) + + return result @router.put("/api/agents/{name}") @@ -472,6 +542,15 @@ async def deploy_agent_endpoint(request: Request, body: DeployAgentRequest): We never silently retarget a pinned deploy. 6. Model not found anywhere in the cluster — 404. """ + # --- Idempotency guard --- + idempotency_cache = getattr(request.app.state, "idempotency_cache", None) + idempotency_key = request.headers.get("Idempotency-Key") + if idempotency_key and idempotency_cache is not None: + mode, event = idempotency_cache.try_reserve(idempotency_key) + if mode == "wait": + await event.wait() + return idempotency_cache.get(idempotency_key) + config = request.app.state.config display_name = body.name.strip() name_error = validate_agent_name(display_name) @@ -775,7 +854,12 @@ async def _background_deploy(): logger.exception("archive smoke-check failed for %s", unique_slug) smoke_ok = False - return {"status": "deploying", "name": body.name, "archive_smoke_ok": smoke_ok} + result = {"status": "deploying", "name": body.name, "archive_smoke_ok": smoke_ok} + + if idempotency_key and idempotency_cache is not None: + idempotency_cache.set(idempotency_key, result) + + return result @router.post("/api/agents/bulk/start") diff --git a/tinyagentos/routes/cluster.py b/tinyagentos/routes/cluster.py index ac40f93e..7e44d535 100644 --- a/tinyagentos/routes/cluster.py +++ b/tinyagentos/routes/cluster.py @@ -656,3 +656,57 @@ async def worker_remote_command(request: Request, name: str, body: WorkerRemoteR return resp.json() except Exception as exc: return JSONResponse({"error": str(exc)}, status_code=502) + + +@router.get("/api/cluster/promote-archived") +async def promote_archived_models(request: Request): + """Manual trigger: scan all online workers and promote any archived + models that are now compatible with cluster hardware. + + Called by the user from the Cluster page or admin CLI. Safe to call + repeatedly — already-promoted models are skipped. + """ + cluster = request.app.state.cluster_manager + notifications = getattr(request.app.state, "notifications", None) + + workers = cluster.get_workers() + online = [w for w in workers if w.status == "online"] + + from tinyagentos.cluster.model_archive import ( + find_promotable, + promote_model, + ) + + promoted_by_worker: dict[str, list[str]] = {} + total = 0 + + for w in online: + promotable = find_promotable( + worker_hardware=w.hardware, + worker_name=w.name, + ) + for model in promotable: + model_id = model.get("model_id", "?") + if promote_model(model): + promoted_by_worker.setdefault(w.name, []).append(model_id) + total += 1 + if notifications: + try: + await notifications.emit_event( + "model.promoted", + f"Archived model '{model_id}' promoted", + f"Worker '{w.name}' can now run '{model_id}'. " + f"Moved from archive to active models.", + level="info", + ) + except Exception: + logger.exception( + "notification emit failed for model promotion %s", + model_id, + ) + + return { + "promoted": total, + "by_worker": promoted_by_worker, + "workers_scanned": len(online), + } diff --git a/tinyagentos/routes/container_shell.py b/tinyagentos/routes/container_shell.py new file mode 100644 index 00000000..68ef69b3 --- /dev/null +++ b/tinyagentos/routes/container_shell.py @@ -0,0 +1,254 @@ +"""Container shell — browser-based terminal for agent containers. + +Provides a standalone HTML page (Pico CSS + htmx) that lets users run +commands inside agent containers via ``incus exec``. Separate from the +WebSocket PTY bridge used by the React desktop SPA for resilience — this +page works without JavaScript bundling. + +Routes: + GET /api/container-shell/{agent_id} — HTML terminal page + POST /api/container-shell/{agent_id}/exec — execute a command, returns HTML +""" + +from __future__ import annotations + +import asyncio +import html +import logging +import re + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# -- runtime auto-registers the /static mount (app.py) ------------------------ +_PICO_CSS = "/static/pico.min.css" +_HTMX_JS = "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" + +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + +_MAX_COMMAND_LENGTH = 4096 +_EXEC_TIMEOUT = 30 + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape sequences from terminal output.""" + return _ANSI_ESCAPE.sub("", text) + + +async def _exec_in_container(container: str, command: str) -> tuple[int, str]: + """Run a command inside a container via ``incus exec``. + + Returns (returncode, output). The output has ANSI escapes stripped + and is HTML-escaped by the caller. + """ + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "incus", "exec", container, "--", "bash", "-lc", command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout_bytes, _ = await asyncio.wait_for( + proc.communicate(), timeout=_EXEC_TIMEOUT, + ) + except FileNotFoundError: + return 127, "incus: command not found (is Incus installed?)" + except asyncio.TimeoutError: + if proc is not None: + try: + proc.kill() + await proc.wait() + except Exception: + pass + return 124, f"(command timed out after {_EXEC_TIMEOUT}s)" + except Exception as exc: + return 1, f"(error: {exc})" + + output = stdout_bytes.decode("utf-8", errors="replace") if stdout_bytes else "" + return (proc.returncode or 0), _strip_ansi(output) + + +# ── HTML page ─────────────────────────────────────────────────────────────── + +_CONTAINER_SHELL_HTML = r""" + + + + +Container Shell — {agent_id} — TinyAgentOS + + + + +
+ 💻 + Container: {container_name} + {agent_id} +
+ +
+
+ Container shell ready. Type a command below. +
+
+ +
+
+ + + +
+
+ + + + + +""" + + +@router.get("/api/container-shell/{agent_id}", response_class=HTMLResponse) +async def container_shell_page(agent_id: str, request: Request): + """Serve the container shell HTML page for an agent. + + The page uses htmx to POST commands to the /exec endpoint and streams + output into the log region. No JavaScript build step required. + """ + container_name = f"taos-agent-{agent_id}" + html_str = _CONTAINER_SHELL_HTML + html_str = html_str.replace("$$PICO_CSS$$", _PICO_CSS) + html_str = html_str.replace("$$HTMX_JS$$", _HTMX_JS) + html_str = html_str.replace("$$MAX_CMD_LEN$$", str(_MAX_COMMAND_LENGTH)) + html_str = html_str.replace("{agent_id}", html.escape(agent_id)) + html_str = html_str.replace("{container_name}", html.escape(container_name)) + return HTMLResponse(html_str) + + +@router.post("/api/container-shell/{agent_id}/exec", response_class=HTMLResponse) +async def container_shell_exec(agent_id: str, command: str = Form(...)): + """Execute a command inside the agent container and return HTML fragment.""" + if not command or not command.strip(): + return HTMLResponse( + '
(empty command)
' + ) + + command = command.strip() + if len(command) > _MAX_COMMAND_LENGTH: + return HTMLResponse( + '
Command too long ' + f'(max {_MAX_COMMAND_LENGTH} characters)
' + ) + + container_name = f"taos-agent-{agent_id}" + escaped_cmd = html.escape(command) + escaped_container = html.escape(container_name) + + rc, output = await _exec_in_container(container_name, command) + + if rc == 127: + # incus not installed + return HTMLResponse( + f'
$ {escaped_cmd}
' + f'
{html.escape(output)}
' + ) + if rc == 124: + return HTMLResponse( + f'
$ {escaped_cmd}
' + f'
{html.escape(output)}
' + ) + + escaped_output = html.escape(output.rstrip()) if output else "(no output)" + return HTMLResponse( + f'
$ {escaped_cmd}
' + f'
{escaped_output}
' + ) diff --git a/tinyagentos/templates/activity_feed.html b/tinyagentos/templates/activity_feed.html new file mode 100644 index 00000000..bf8a4bf4 --- /dev/null +++ b/tinyagentos/templates/activity_feed.html @@ -0,0 +1,314 @@ + + + + + +Model Activity — TinyAgentOS + + + + + +
+

⚡ Model Activity

+
+ + + + +
+
+ +
+ + Connected + 0 events +
+ +
+
+ + + +

Waiting for events…

+
+
+ + + + \ No newline at end of file